A real/bogus classifier scores cutouts around a candidate to distinguish the labeled real-source class from artifacts, such as cosmic rays or badly subtracted stars. xScan (cuphoton.xscan) trains and runs the transformer family of Inada et al. (2026) with PyTorch. Pair models use target and reference cutouts; triplet models also use the difference image. Its command-line workflow packages cutouts as a dataset, checks it, trains, evaluates, predicts and queues candidates for human review. Use xScan after Chapter 6 has found the candidates.
The output named probability is a sigmoid score between zero and one. Training does not calibrate it into an observed probability that a candidate is astrophysical. Check calibration and the operating threshold on representative held-out data before interpreting a score such as 0.9 as a 90% chance of being real.
You need:
cuPhoton with the torch extra on a CPU or the gpu extra on a GPU host (Chapter 1). The review viewers also need viz.
For this triplet example, three cutouts of the same square size, centered on each candidate. Training and evaluation also need labels: 1 for real, 0 for bogus.
NoteSample data
This page runs on a small synthetic set of cutouts. The printed values only demonstrate the API.
8.1 Make labeled cutouts
The block below makes 240 synthetic candidates of 21 by 21 pixels, in units of the sky noise. Half are labeled real, with a new source in the target. The other half simulate artifacts: a stationary star misregistered by one pixel between the images, or a cosmic ray (one bright pixel in the target). A genuinely moving source can produce the same dipole shape; its label depends on your science target, not on the shape alone.
label_source records where a label came from. split_group names the object a candidate belongs to. Keep all candidates of one object in one split. The dataset check also requires exposure_id, ccd_id (the detector chip), band (the filter) and the cutout center x, y. Use placeholders when your data has none.
8.3 Check the dataset
validate_dataset_dir checks shapes, splits and metadata fields. The label guard, check_training_label_provenance, checks that the labels are binary, match the metadata, have a named source, cover both classes and keep each split_group in one split.
Training runs the label guard again and stops when it fails.
8.4 Train a model
Training reads a YAML file. The model settings below make a tiny network that trains in seconds on a CPU. For the default network, which takes 51-pixel cutouts, start from the repository’s examples/xscan/train-triplet.example.yaml.
import shutilPath("runs").mkdir(exist_ok=True)Path("runs/classify-train.yaml").write_text("""\dataset_dir: sample/cutoutsoutput_root: runsrun_name: classify-demoepochs: 2batch_size: 16early_stopping_metric: val_loss # keep the epoch with the lowest validation lossdevice: auto # CUDA when PyTorch finds a GPU, otherwise the CPUmodel: image_size: 21 # cutout side in pixels depths: [1, 1] # two small encoder stages, for this demo only num_heads: [1, 1] embed_dims: [16, 16] decoder_embedding_dim: 16""")shutil.rmtree("runs/classify-demo", ignore_errors=True) # xScan never writes into an existing run
Train the triplet model, which reads all three channels. train-inada-pair reads the target and reference only. The command prints a JSON summary. Save it to a file:
The run directory runs/classify-demo/ holds checkpoint.pt, the full configuration config.yaml, the per-epoch history.json and the run summary.json.
summary = json.loads(Path("runs/classify-train.json").read_text())print("device:", summary["device"], "| saved epoch:", summary["early_stopping"]["best_epoch"],"| selected by:", summary["best_metric"]["name"])for epoch in json.loads(Path("runs/classify-demo/history.json").read_text()):print(f"epoch {epoch['epoch']:.0f}: train loss {epoch['train_loss']:.3f}, "f"validation loss {epoch['val_loss']:.3f}, validation ROC AUC {epoch['val_roc_auc']:.3f}")
device: cpu | saved epoch: 2 | selected by: val_loss
epoch 1: train loss 0.531, validation loss 0.596, validation ROC AUC 1.000
epoch 2: train loss 0.069, validation loss 0.117, validation ROC AUC 1.000
The checkpoint holds the epoch with the best selection metric, which is not always the last epoch. When early_stopping_metric is unset, selection uses validation ROC AUC (defined in the next step), or validation loss for a single-class validation split. Early stopping remains disabled unless a metric or patience is set.
8.5 Evaluate on the test split
evaluate-real-bogus scores one split, test by default, and writes metrics, curves and per-candidate predictions to runs/classify-demo/evaluation/test/.
ROC AUC measures how often a random labeled-real candidate outranks a labeled-bogus one, counting ties as half. PR AUC is the trapezoidal area under the precision-recall curve. A candidate is classified as real when its score reaches the threshold. Next to the fixed threshold of 0.5, the command picks the threshold with the best validation accuracy and applies it to the test split. summary.md in the same directory is a readable version of the report.
These metrics describe this small synthetic test split. They do not establish survey performance or score calibration. On real data, choose a threshold on validation data for the missed-source and false-positive rates you can accept, then evaluate it once on an independent test split. Precision also depends on how common real sources are in the scored population.
8.6 Predict
infer-real-bogus only scores. It writes the logits and probabilities of one split to runs/classify-demo/inference/test/.
The arrays follow the order of the test split in the dataset.
8.7 Score your own cutouts in Python
For cutouts in memory, load the checkpoint and call the model. load_model_from_checkpoint rebuilds the network, loads the saved weights and sets evaluation mode. The triplet model takes a float32 tensor of shape (candidate, channel, y, x), with the channels target, reference, difference. It returns one logit per candidate. Its sigmoid is the model’s real-class score, exposed as probabilities by the inference commands.
p(real): [0.909 0.065 0.933 0.039 0.925 0.326]
same as infer-real-bogus: True
Figure 8.1 shows test cutouts of each kind with these probabilities.
Plotting code
import matplotlib.pyplot as plttest = np.flatnonzero(split ==2)picks = [i for k inrange(3) for i in test[kind[test] == k][:2]]names = ["new source", "misregistered", "cosmic ray"]fig, axes = plt.subplots(3, 6, figsize=(6.5, 3.8))for col, i inenumerate(picks): top =max(target[i].max(), reference[i].max())for row, (image, cmap, lo, hi) inenumerate([(target[i], "magma", -3, top), (reference[i], "magma", -3, top), (difference[i], "RdBu_r", -top /2, top /2)]): axes[row, col].imshow(image, origin="lower", cmap=cmap, vmin=lo, vmax=hi) axes[row, col].set_xticks([]); axes[row, col].set_yticks([]) p = probabilities[np.searchsorted(test, i)] axes[0, col].set_title(f"{names[kind[i]]}\n$p$ = {p:.2f}", fontsize=8)for row, label inenumerate(["Target", "Reference", "Difference"]): axes[row, 0].set_ylabel(label, fontsize=8)fig.text(1, 1, "Synthetic data, for demonstration only", ha="right", va="bottom", fontsize=7, style="italic", color="0.4", transform=fig.transFigure)fig.tight_layout()plt.show()
Figure 8.1: Six test cutouts, two of each kind in the synthetic set, titled with the model’s real-class score \(p\). Rows are the target, the reference and the difference. The target and reference share one magma scale per column, and the difference panels a red-blue scale centered on zero. A new source appears in the target and the difference only. A misregistered star leaves a positive and a negative lobe in the difference. A cosmic ray is one sharp pixel. Synthetic data, for demonstration only.
8.8 Handle checkpoints
A checkpoint is a dictionary. model_config holds the network’s shape, model_state its weights, train_config the training configuration and xfit_feature_bundle the identity of optional xFit features.
load_model_from_checkpoint passes model_config to build_model and loads model_state into the result. Call build_model(**checkpoint["model_config"]) to build the same network in your own PyTorch code. A checkpoint only works on cutouts with its input mode, size and preprocessing. A wrong size fails late, inside the network. To reject a checkpoint before any model is built, pass a checkpoint_validator, a function that raises ValueError:
def require_triplet(size):def check(checkpoint): config = checkpoint["model_config"]if config["input_mode"] !="triplet"or config["image_size"] != size:raiseValueError(f"need a triplet model for {size} px cutouts, got "f"{config['input_mode']} for {config['image_size']} px")return checktry: load_model_from_checkpoint(Path("runs/classify-demo"), checkpoint_validator=require_triplet(51))exceptValueErroras error:print("ValueError:", error)
ValueError: need a triplet model for 51 px cutouts, got triplet for 21 px
8.9 Queue candidates for human review
Reviewers correct and extend labels through a review queue. review-queue ranks the predictions of one split. The default strategy, hybrid, favors candidates near the decision threshold most.
writes a CSV to fill in, then validates it into annotations.jsonl
Check
review-status
reports whether every item has enough agreeing reviews (by default two, unanimous)
Decide
review-aggregate
turns each reviewer’s latest annotation into a decision
Apply
review-apply
writes a new dataset with the reviewed labels
The queue and the append-only annotations.jsonl are the review record. review-apply writes to a new --output-dir and leaves the source dataset unchanged.
8.10 Options
Table 8.3 lists the main settings of the training YAML file and the commands.
Table 8.3: Main options. The first rows go in the training YAML file, the last four are command flags.
Option
Default
Effect
epochs
4
passes over the training split
batch_size
16
candidates per training step
learning_rate
0.001
optimizer step size
device
auto
CUDA when available, otherwise the CPU
early_stopping_metric
none
Set to val_roc_auc, val_pr_auc or val_loss to select the saved epoch and turn on early stopping. When unset, selection uses val_roc_auc, or val_loss for a single-class validation split.
early_stopping_patience
none
epochs without improvement before training stops (0 when only the metric is set)
model.image_size
51
cutout side in pixels
model.depths, num_heads, embed_dims
six stages, 64 to 512 wide
network size
use_xfit_features, xfit_feature_dir
off
adds xFit fit features of each difference cutout
performance.amp_dtype
off
bf16 or fp16 mixed precision, GPU only
--split (infer, evaluate, review-queue)
test
split to score
--batch-size (infer, evaluate)
32
candidates per inference batch
--strategy (review-queue)
hybrid
queue ranking, also uncertainty or known-errors
--max-items (review-queue)
200
queue length
8.11 On a GPU
With the gpu extra, the same commands run on CUDA, which device: auto selects. GPU settings go in the performance section of the YAML file. They are off by default. On a CPU, xScan ignores mixed precision and pinned memory.
For cutouts already on the GPU, predict_tensors scores a contiguous float32 tensor on an explicit CUDA device and returns logits and probabilities on it:
predict_fits cuts and scores cutouts straight from aligned FITS images on the GPU, without a dataset directory. It needs CuPy and an explicit CUDA device, and the cutout sides in stamp_shape must be odd. It does not align, subtract or normalize the images.
The array format, the model and the checkpoints are generic. These parts depend on the survey:
Cutout preparation. Size, centering, scaling and masking are your choice. A checkpoint is valid only for cutouts prepared like its training set.
Dataset builders.cuphoton xscan --help lists builders for several surveys’ products, such as data-build-autoscan. Example manifests are in examples/xscan/.
Labels. Define which astrophysical changes count as real for your task. The label guard rejects placeholder labels from survey smoke datasets and survey object IDs that appear in two splits; it cannot verify that a human label is scientifically correct.
Thresholds and calibration. Choose the threshold on a representative validation split, and check performance and score calibration on held-out data with the class balance and observing conditions you expect to encounter.
Each training run needs a new directory. Change run_name or delete the old run.
ERROR: refusing to train with group leakage across train/validation/test splits, or refusing to train on labels that are not real-bogus usable: label_source_untrusted
The label guard failed. Run data-check-training-labels and fix the listed metadata. A label_source that is empty or contains “placeholder” or “unlabeled” is untrusted.
RuntimeError: The expanded size of the tensor (676) must match the existing size (121)
The cutouts differ in size from the checkpoint’s image_size. Use a checkpoint_validator to catch this at load time.
ValueError: triplet model expects 3-channel stacked input
Stack the channels target, reference and difference, in that order. A pair model takes the first two.