A walkthrough of every stage between metadata.csv and a reported score, with the reasoning for each decision.
This notebook does not train anything. It runs in seconds and needs no image files: the data stages work from the metadata alone, and the model stages are demonstrated on small synthetic inputs. Training is ham10000 train configs/<name>.yaml; results are in 02_results.ipynb.
The stages:
metadata.csv
-> load and validate
-> label scheme (which classes, and how they are grouped)
-> lesion-level split (the leakage control)
-> balancing (training set only)
-> validation expansion (test-time augmentation)
-> transform
-> model (architecture and freezing)
-> training
-> inference (per-image probabilities)
-> aggregation (per-image -> per-lesion)
-> decision rule (sensitivity bias)
-> evaluation
load_metadata adds a num_images column and refuses a file with duplicate image_id values. That check matters: nothing downstream would fail loudly on a repeated identifier, but the split would silently designate the same image for a lesion twice.
3.2 2. Label scheme
The seven diagnoses can be modelled individually, or grouped. LabelScheme turns a specification into the two mappings the pipeline needs: diagnosis to integer, and integer to class name.
Code
from ham10000.data import LabelSchemepresent =set(metadata["dx"].unique())seven = LabelScheme.build(["mel", "nv", "bkl", "bcc", "akiec", "vasc", "df"], present)binary = LabelScheme.build( {"malignant": ["mel", "bcc", "akiec"], "benign": ["nv", "bkl", "df", "vasc"]}, present,)# Naming two diagnoses does not by itself give a two-class problem: the other# five are still in the data and have to go somewhere.mel_nv = LabelScheme.build(["mel", "nv"], present)# Restricting the data first is what makes it genuinely binary. This is what# `restrict: {dx: [mel, nv]}` does in the demo configuration.restricted = LabelScheme.build(["mel", "nv"], {"mel", "nv"})print("seven-class :", seven.codes)print("malignant/benign:", binary.codes)print("mel and nv named:", mel_nv.codes)print("data restricted :", restricted.codes)import pandas as pdpd.DataFrame( { name: {"melanoma index": scheme.mapping["mel"],"called": scheme.codes[scheme.mapping["mel"]],"classes": scheme.n_classes,"chance": round(1/ scheme.n_classes, 3), }for name, scheme in {"seven-class": seven,"malignant/benign": binary,"mel and nv named": mel_nv,"data restricted": restricted, }.items() }).T
Table 3.1: Where melanoma lands under four class schemes.
melanoma index
called
classes
chance
seven-class
4
mel
7
0.143
malignant/benign
1
malignant
2
0.5
mel and nv named
1
mel
3
0.333
data restricted
0
mel
2
0.5
Naming mel and nv gives three classes, not two (Table 3.1). The other five diagnoses are still in the data, so they collect in other, and the model has a third answer available. Restricting the data first is what makes the problem genuinely binary, which is why the demo configuration sets restrict: {dx: [mel, nv]} before naming its classes.
Note that melanoma has a different integer under every scheme. That is not an inconsistency to be fixed, it is what an integer label means: a position in a particular scheme, assigned by sorting the class names, with other pinned to index 0 when it exists. A stored probability table has a column per class in that order, and a trained model has one output per class in that order, so a checkpoint and a scheme only make sense together. Scoring a seven-class checkpoint under a binary scheme would silently read the first two of seven outputs and produce entirely plausible nonsense, which is why predict_probabilities refuses when the widths disagree.
Two conventions are load-bearing, because stored artefacts depend on them.
Class names are sorted before indices are assigned, so the same specification always yields the same integer for the same class. Without that, a model trained in one session is silently mismatched with predictions decoded in another.
A specification that does not cover every diagnosis gains an other class at index 0, shifting the rest up. Groups must also be disjoint: a diagnosis appearing in two of them is rejected rather than quietly assigned to whichever was declared last.
partial specification: {0: 'other', 1: 'mel', 2: 'nv'}
bkl falls into : other
probability columns : ['prob_other', 'prob_mel', 'prob_nv']
3.3 3. The lesion-level split
The most consequential decision in the project. 1,956 lesions are photographed more than once, and those images are near-duplicates. Splitting at the image level would place near-identical photographs of one lesion on both sides of the boundary, so the validation score would measure memorisation rather than generalisation.
Code
from ham10000.data import SplitConfig, assign_splits, lesion_overlapmetadata["label"] = metadata["dx"].map(seven.mapping)split = SplitConfig(train_val_ratio=3, seed=0, keep_first=False, stratified=True)assignment = assign_splits(metadata, split)annotated = metadata.assign(set=assignment.sets)print(f"train lesions {len(assignment.train_lesions):,}")print(f"val lesions {len(assignment.val_lesions):,}")print(annotated["set"].value_counts().sort_index().to_string())print("\nlesions on both sides:", lesion_overlap(annotated) or"none")
train lesions 5,600
val lesions 1,870
set
t1 5600
ta 1878
v1 1870
va 667
lesions on both sides: none
The set column encodes two experiments at once:
value
meaning
t1
training, and the one designated image for its lesion
ta
training, an additional image of a lesion already represented
v1
validation, designated image
va
validation, additional image
One image per lesion uses t1/v1; all images uses t1 | ta and v1 | va. Since ta is defined as the images of training lesions excludingt1, the union is exactly the images of the training lesions, with no double counting.
stratified=True applies the ratio within each class. With dermatofibroma at 73 lesions, an unstratified split can leave a rare class badly represented on one side by chance.
Code
# Both images of a multi-image lesion land on the same side, always.multi = annotated[annotated["num_images"] >1]sides = multi.assign(side=multi["set"].str[0])print("lesions whose images span both sides:",int((sides.groupby("lesion_id")["side"].nunique() >1).sum()))
lesions whose images span both sides: 0
3.4 4. Balancing
Nevi are 72% of lesions. A classifier answering “nevus” unconditionally scores about 0.72 plain accuracy while being useless, and cross-entropy rewards it for doing so. Resampling toward uniform class counts removes the incentive.
Resampling is lesion-aware, not row-level. To draw N images of a class over its D lesions, write N = Q·D + R: every lesion contributes Q images and R randomly chosen lesions contribute one more. Within a lesion holding k images, Q = q·k + r: each image taken q times, r of them once more.
Sampling is therefore as uniform as integer arithmetic allows at both levels. A lesion photographed five times does not get five times the influence of one photographed once, which is the concern that motivates the split, applied here to resampling.
Table 3.2: Resampling factors by class, for a target of 1,000 each.
before
after
factor
lesions
dx
nv
5007
1000
0.2
4052
bkl
823
1000
1.2
545
mel
827
1000
1.2
460
bcc
388
1000
2.6
245
akiec
244
1000
4.1
171
vasc
107
1000
9.3
73
df
82
1000
12.2
54
Note what the factor column exposes (Table 3.2). Nevi are cut to a fifth; dermatofibroma is repeated twelve times over 54 distinct lesions. Random cropping means those repeats are different views rather than identical copies, but the underlying lesion count does not change, and a recall figure for df says more about those 54 lesions than about the class.
The same arithmetic handles both directions: when N < D, Q = 0, so no lesion contributes a base image and exactly N lesions contribute one each. That is the undersampling path, and it draws from distinct lesions rather than dropping rows arbitrarily.
Balancing is applied to training data only.balance refuses a frame containing validation rows, because repeating a validation lesion would count its errors twice and inflate the reported score.
Frame contains 2537 validation row(s). Balancing must be applied to training data only, or validation performance is inflated by repeated lesions.
3.5 5. Validation expansion
Each validation lesion is scored several times under the evaluation transform and the predictions are combined into a single verdict. This is test-time augmentation, and it only makes sense because the transform is stochastic: with a deterministic transform it would produce identical predictions and cost runtime for nothing. Every lesion is repeated the same number of times, so the expansion cannot reweight the validation set across classes.
Whether this helps is measured in section 9, once there is a run to measure it on.
Code
from ham10000.data import expand_validationvalidation = annotated[annotated["set"].isin(["v1", "va"])].copy()expanded = expand_validation(validation, 3, seed=0)print(f"{len(validation):,} images -> {len(expanded):,} after 3x expansion")print("repeats per lesion:", sorted(int(v) for v in expanded["lesion_id"].value_counts().unique()))
2,537 images -> 5,610 after 3x expansion
repeats per lesion: [3]
3.6 6. Transform
Declared in the config rather than in code, so an experiment’s augmentation is part of its identity.
Code
from ham10000.experiment import build_transform, load_configconfig = load_config(settings.root /"configs"/"04_balanced_random_crop.yaml")transform = build_transform(config.transform)transform
RandomCrop(300) then Resize(224) takes a random 300x300 window of the 600x450 image and scales it to the network’s input size. That is a meaningful augmentation for this domain: a dermatoscopic image has no canonical framing, so the lesion’s position and scale within the frame carry no diagnostic information and a model should not depend on them.
Normalize uses ImageNet channel statistics, matching the distribution the pretrained weights expect.
Note that this same transform is applied at validation time, which is what makes the expansion above work.
3.7 7. Model and freezing
Transfer learning: take an ImageNet-pretrained backbone, replace its head with one sized to the task, and train some suffix of the network.
Code
from ham10000.models import FreezeStrategy, apply_freezing, build_classifiermodel = build_classifier("resnet18", n_classes=seven.n_classes, strategy="last_block", pretrained=False)total =sum(p.numel() for p in model.parameters())for strategy in FreezeStrategy: apply_freezing(model, strategy) trainable =sum(p.numel() for p in model.parameters() if p.requires_grad)print(f"{strategy.value:11s}{trainable:>11,} / {total:,} trainable "f"({trainable / total:.2%})")
Early layers hold generic edge and texture filters that transfer well from ImageNet; late layers hold class-specific structure worth re-learning. Freezing the early ones is faster and reduces overfitting on a dataset this size. head_only is a genuine baseline rather than merely a cheap one: it measures how much of the task ImageNet features already solve, with no fine-tuning at all.
3.8 8. Training
train_model is a loop over epochs: forward, loss, backward, step; then a validation pass. Loss is cross-entropy, the optimiser is Adam at 1e-4, a conservative rate appropriate to fine-tuning, since a larger one can destroy pretrained features in the first few steps.
Only the trainable parameters go to the optimiser. Handing frozen tensors to Adam would allocate optimiser state that never updates, and for optimisers with weight decay can perturb weights that were meant to stay fixed.
The final epoch is saved rather than the best. With no early stopping, a run that begins overfitting at epoch 5 still ships epoch 10, which is a real weakness and visible in the loss curves in 02_results.ipynb. Setting save_best keeps the lowest-validation-loss epoch instead, with a caveat: choosing an epoch by validation loss and then reporting validation metrics is selection on the evaluation set, so the reported score is optimistic by an unknown amount. Note also that the selection criterion and the reported metric are different quantities, so the lowest-loss epoch is not necessarily the most accurate one.
Code
print(f"epochs {config.training.epochs}")print(f"batch size {config.training.batch_size}")print(f"learning rate {config.training.learning_rate}")print(f"save best {config.training.save_best}")
epochs 10
batch size 32
learning rate 0.0001
save best False
3.9 9. Inference and aggregation
The model scores each image independently. Something has to reconcile several images of one lesion into a single answer, because a lesion is what a clinician decides about. There are two places to do it, and they are not equivalent.
Code
import pandas as pd# A small synthetic probability table: one lesion, three views.example = pd.DataFrame({"lesion_id": ["L1", "L1", "L1"],"prob_mel": [0.20, 0.55, 0.30],"prob_nv": [0.80, 0.45, 0.70],})example
lesion_id
prob_mel
prob_nv
0
L1
0.20
0.80
1
L1
0.55
0.45
2
L1
0.30
0.70
Code
from ham10000.models import aggregate_probabilities, predicted_label# Combine probabilities first, then take one argmax.combined = aggregate_probabilities(example, {"max": ["mel"], "min": ["nv"]})comparison = example[["prob_mel", "prob_nv"]].copy()comparison.columns = ["mel (per image)", "nv (per image)"]comparison["per-image verdict"] = predicted_label(example)comparison["mel (lesion)"] = combined["prob_mel"]comparison["nv (lesion)"] = combined["prob_nv"]comparison
Table 3.3: Per-image probabilities and the lesion-level values that replace them.
mel (per image)
nv (per image)
per-image verdict
mel (lesion)
nv (lesion)
0
0.20
0.80
nv
0.55
0.45
1
0.55
0.45
mel
0.55
0.45
2
0.30
0.70
nv
0.55
0.45
Choosing max for melanoma (Table 3.3) says: if any view of this lesion looked malignant, treat the lesion as that likely to be malignant. That is an explicitly sensitivity-favouring rule, appropriate to screening. The lesion columns are constant by construction, since the whole point is to replace each image’s estimate with one figure for the lesion.
The alternative is to let each image vote and combine the labels.
Code
from ham10000.models import aggregate_predictionsvoted = example.assign(pred=predicted_label(example))aggregate_predictions(voted, seed=0)[["lesion_id", "pred", "pred_final"]]
lesion_id
pred
pred_final
0
L1
nv
nv
1
L1
mel
nv
2
L1
nv
nv
Ties are broken at random, with a seed. The alternative is to take the first value in sorted order, which for integer labels means the lowest, and since other and nv occupy low indices that would quietly resolve tied lesions toward the benign class: the wrong direction for this problem.
Both rules are defensible. Whether the choice matters is a separate question, and a measurable one. The rest of this section uses the predictions from a completed run rather than the toy example above.
Code
run =next( p for p insorted((settings.root /"models").iterdir())if p.name.startswith("balanced-with-random-crop") andnot p.name.endswith("-smoke"))predictions = pd.read_csv(run /"predictions.csv")views_per_lesion = predictions.groupby("lesion_id")["pred"].nunique()print(f"lesions whose views disagreed: {(views_per_lesion >1).sum():,} "f"of {len(views_per_lesion):,} ({(views_per_lesion >1).mean():.1%})")
lesions whose views disagreed: 432 of 1,870 (23.1%)
Code
# The route the pipeline used: vote on per-image labels -> pred_final.# The alternative: combine probabilities first, then take one argmax.by_probability = aggregate_probabilities(predictions, {"max": ["mel"]})by_probability["alt"] = predicted_label(by_probability, seven.codes)per_lesion = by_probability.drop_duplicates("lesion_id")differ = per_lesion["alt"] != per_lesion["pred_final"]print(f"routes disagree on {differ.sum():,} of {len(per_lesion):,} lesions "f"({differ.mean():.1%})")
routes disagree on 175 of 1,870 lesions (9.4%)
Views disagreed on 432 of 1,870 lesions (23.1%), but most of those are settled the same way by both rules. On 175 lesions (9.4%, about one in eleven) the two routes reach different verdicts. For those, the choice of aggregation rule is the answer.
The 23.1% also explains why the score moves between evaluations. Those lesions sit near the decision boundary, and re-cropping shifts some of them across it.
Code
disagreeing = per_lesion[differ]# Prefer a case where combining probabilities lands on melanoma, since that is# the direction the `max` rule is chosen for.melanoma = seven.mapping["mel"]preferred = disagreeing[disagreeing["alt"] == melanoma]chosen = (preferred iflen(preferred) else disagreeing).iloc[0]["lesion_id"]views = predictions[predictions["lesion_id"] == chosen]table = views[seven.probability_columns].round(3)table["per-image verdict"] = views["pred"].map(seven.codes)print(f"lesion {chosen}, one of {len(disagreeing)} where the two routes differ")print(f"true diagnosis : {views['dx'].iloc[0]}")print(f"vote on labels : {seven.codes[views['pred_final'].iloc[0]]}")print(f"combine probabilities : "f"{seven.codes[disagreeing.set_index('lesion_id').loc[chosen, 'alt']]}")table
lesion HAM_0002730, one of 175 where the two routes differ
true diagnosis : bkl
vote on labels : bcc
combine probabilities : mel
Table 3.4: One validation lesion where the two aggregation routes differ.
prob_akiec
prob_bcc
prob_bkl
prob_df
prob_mel
prob_nv
prob_vasc
per-image verdict
0
0.009
0.001
0.150
0.000
0.840
0.000
0.000
mel
1
0.071
0.537
0.185
0.008
0.189
0.004
0.006
bcc
2
0.012
0.242
0.690
0.004
0.047
0.002
0.003
bkl
Lesion HAM_0002730 is a benign keratosis. One view read it as melanoma at 0.84, another as basal cell carcinoma at 0.54, the third as keratosis at 0.69. Voting gives bcc; combining probabilities with max on melanoma gives mel.
Both are wrong, which makes this a more useful example than one where the sensitivity-favouring rule is vindicated. It shows the cost of the choice rather than only the benefit: max turns a single confident view into a melanoma call, and here that view was mistaken. In screening terms this is a false alarm and a biopsy, which is the price paid for catching the cases where the one suspicious view is right.
This also lets us check whether the expansion in section 5 earns its place. Over ten evaluations at each setting, three views scored 0.694 against 0.686 for one view, and the run-to-run spread fell from 0.018 to 0.014. The 23.1% figure above is why that spread exists at all: those lesions sit near the boundary, and re-cropping moves some of them across it. Neither difference is significant at this sample size, so the technique is retained because it is standard and costs little, not because this dataset shows it earning its keep.
3.10 10. The decision rule
Plain argmax is wrong for melanoma screening. A lesion at 45% melanoma against 50% nevus is called a nevus, and the two errors are not symmetric: a missed melanoma delays treatment of a cancer with a sharp survival gradient in time to diagnosis, while a false alarm costs a biopsy.
Two rules are available. They are different rules, not two implementations of one, and they disagree on roughly one lesion in six.
Table 3.5: Three decision rules on the same three probability vectors.
plain argmax
priority (A)
cost-sensitive (C)
0
nv
mel
mel
1
nv
nv
mel
2
bcc
mel
bcc
Rule A, priority-ordered (Table 3.5), walks the list and promotes the first class clearing its bar, then stops. The ordering carries the clinical priority: melanoma is checked before basal cell carcinoma because it matters more. It is a gate: below the bar it does nothing at all, which is why row 2 stays nv. The rule is unorthodox: most work applies per-class thresholds independently or reweights, rather than walking an ordered list and stopping. However, the ordering makes the clinical priority explicit in a way a set of weights does not.
Rule C, cost-sensitive, divides each probability by its threshold and takes the argmax. It is an unconditional reweighting, so it acts even when nothing crosses a bar, and row 2 flips to mel. Its effective condition is p_mel > 0.4 · p_nv rather than p_mel > 0.4 (much weaker).
Rule A is the default. It is easy to reason about, and its priority ordering is an explicit statement of which class matters most, which suits a screening problem. Rule C is the more standard method and is available as an alternative; switching would change every reported figure, so the two are kept separate rather than swapped.
Row 3 shows why a third variant is unattractive. If both classes over 0.4 were promoted to 1.0, the tie would fall to column order, which is alphabetical, so bcc would beat mel because “b” precedes “m”. That puts an arbitrary rule where a clinical one belongs, and it is the reason Rule A stops at the first class rather than promoting all of them.
3.11 11. Evaluation
Per lesion, not per image, so a lesion photographed five times counts once.
Balanced accuracy, the mean of per-class recall, is the headline, because plain accuracy rewards predicting the majority class. Its chance level is 1/n_classes, so scores from tasks with different class counts are not directly comparable.
F2 weights recall four times as heavily as precision, which is the direction a screening tool should err in. F1/2 is reported alongside for contrast, not because it is appropriate here.
Support sits beside recall because a recall of 1.00 over 18 lesions is not evidence of much.