Method, and everything wrong with it.
An audit tool earns its authority by being checkable. Everything below is reproducible from two public datasets on a single consumer GPU, and every limitation we know of is stated here rather than buried in an appendix. If you find an error, it matters more to us than to you.
What is being measured
Binary lesion triage: should a clinician look at this? Not a diagnosis and not a cancer classification. Malignant and pre-malignant lesions (melanoma, basal-cell carcinoma, squamous-cell carcinoma, actinic keratosis) count as suspicious; nevi and seborrheic keratoses count as benign.
Mapping actinic keratosis to suspiciousis a real judgement call — it is pre-malignant, and it is 730 of PAD-UFES-20’s 2,298 rows, so the choice moves every number on the site. We state it rather than bury it.
The two datasets, and why there are two
PAD-UFES-20 (CC BY 4.0, 2,298 smartphone clinical images, 1,373 patients) is the only free, commercially licensed dermatology dataset carrying both biopsy-confirmed cancer labels and Fitzpatrick skin tone. It is what a real product would be built on. It contains 11 Fitzpatrick V–VI images, and 804 images with no tone label at all.
Fitzpatrick17k (CC BY-NC-SA 3.0) is non-commercial, so nothing derived from it may ever be sold. Restricted to neoplastic lesions it carries 93 malignant dark-skin cases — enough to certify. We train a second model on it purely to measure what the commercial data cannot show, and that model is quarantined: assert_commercial_only() raises if any research-licensed sample reaches a training or export path that could ship.
A caveat on that second set that we would rather not have found: 75.9% of its cancer images are gone. Fitzpatrick17k distributes URLs rather than pixels, and one of its two host sites now returns 404 for every file. We retrieved 1,083 of 4,497. So our research-lane numbers are computed on the surviving quarter of a benchmark, which is a genuine weakness of the comparison and is why we do not lean on its exact values — only on the fact that the gap is absent.
Splits — the anti-leakage gate
PAD-UFES-20 holds 2,298 images across 1,641 lesions across 1,373 patients, so the same patient recurs across rows. Splits are therefore taken at the patient level: every image of every lesion of a given patient lands in exactly one split, and the code raises if that is ever violated. Splitting on the image instead — the easy mistake — leaks a patient between train and test and silently inflates every metric.
Model selection used the calibration split, never the test split. Our calibration-split AUROC was 0.980; the untouched test split gave 0.864. That 0.116 drop is exactly the self-congratulation you buy by selecting on your test set, and it is why we did not.
Fitzpatrick17k has no patient identifier at all — it is scraped from public atlases — so a patient-level split is impossible there. We stratify by tone and label instead, and we say so rather than implying a rigour we cannot deliver.
Skin tone is never a model input
Tone travels alongside the predictions so the audit can stratify them. It is never fed to the classifier. Handing tone to the model would let it learn each group’s base rate and score a dark-skinned patient on their demographics rather than on their lesion — and the audit downstream would then be measuring a model that had been given the answer key.
For the same reason the training sampler balances by class (benign vs suspicious) and never by tone. Oversampling 11 dark-skin images until the model memorised them, then reporting a flattering number on them, would be fraud dressed as engineering.
What the audit refuses to do
A bin is certified at ≥30 positive cases, directional at ≥10, and left blank below that. The width of a binomial interval on sensitivity is roughly ±1/√n; at eight cases that is ±35 percentage points, which is not an estimate, it is a rumour.
No pass/fail fairness certificate is issued, ever. A bright-line badge implies a precision the statistics cannot support, and it is exactly the artifact a vendor would wave at a regulator to end a conversation that ought to continue. Every figure carries a bootstrap confidence interval, and the resampling is stratified within each tone bin — a naive bootstrap over the whole set draws zero positives from an 11-image bin in most rounds, discards those draws, and quietly biases the interval in the direction that flatters the customer.
The calibration fix, and the time it made things worse
The remediation we sell is post-hoc per-tone temperature scaling: it changes no weights, needs no retraining, and runs in seconds. It corrects over-confidence. It does not create competence — it cannot teach a model about skin it has never seen.
The first version applied a temperature wherever it had ten positive cases. Run against our own research model it duly fitted one for dark skin from about fifteen cases, and that temperature made held-out calibration worse— ECE 0.145 → 0.172. The “fix” damaged the exact group it existed to help, while looking like a success in-sample. That is the malpractice this company was founded to criticise, committed by us.
So every temperature is now cross-validated out-of-sample and discarded unless it demonstrably improves the model on data it has not seen. The check runs on held-out log-loss rather than on ECE, because binned ECE over a twenty-sample fold is so noisy that its variance swamps the effect and the guard ends up rejecting good fits along with bad ones. We validate on the statistic that can see the effect and report the one a human can read.
Two engines, one answer
The audit runs in Python (CLI and API) and in TypeScript (in your browser, so your predictions never leave your machine). Two implementations of the same statistics is a real hazard: a customer who gets 0.81 from one and 0.78 from the other has no reason to trust either. So Python is the reference, its outputs on fixed inputs are frozen as golden vectors, and the browser engine is tested against them on every build.
That test has already earned its keep twice. It caught the two engines binning calibration differently (NumPy’s linspaceputs a bin edge at 0.30000000000000004; JavaScript’s 3/10 is the nearest double below 0.3, so a score landing exactly on the edge fell into different bins), and it caught them breaking ties differently when a saturated model makes the log-loss flat. Both would have shipped silently.
Known limitations
- 118 of 361 evaluation cases (33%) carry no skin-tone label. They are excluded from every per-tone number in this report, and no tool — this one or any other — can audit them for tone. Tone-label coverage is itself an audit finding.
- Fitzpatrick V–VI (dark): 1 positive case(s) — below the 10 required for even a directional estimate. No metric is reported for this bin. The correct reading is 'unmeasured', never 'fine'.
- Fitzpatrick III–IV (mid): 62 positive case(s) — below the 10 required for even a directional estimate. No metric is reported for this bin. The correct reading is 'unmeasured', never 'fine'.
- Calibration is fitted post-hoc and does not give the model new information about skin tones it has never seen. It corrects over-confidence; it does not create competence. Where a bin was too small to fit, predictions fall back to a temperature fitted across all tones — i.e. a correction derived from other people's skin.
- All numbers are conditional on the evaluation set (PAD-UFES-20 held-out test split (patient-level)) being representative of the deployment population. Where it is not — different cameras, different lesion mix, a screening rather than a treatment cohort — these numbers will not transfer.
- Stratified by Fitzpatrick (phototype — burn/tan response, NOT a colour scale). This is worth stating plainly: Fitzpatrick was devised in 1975 to predict UV phototherapy response, and types I-IV are defined by self-reported BURN AND TAN BEHAVIOUR, not by colour. Types V and VI were added later — two categories for the majority of the world's skin. Dermatology AI adopted it because it was the label that happened to be in the datasets, not because it measures what we need. The Monk Skin Tone scale is the better instrument and this tool accepts it; we use Fitzpatrick here only because it is what the available data carries.
- PAD-UFES-20’s cohort is Brazilian and skews light. It is not a Māori or Pacific proxy and must never be presented as one. The equity gap this venture exists to close is an Aotearoa gap; no dataset in this repository speaks to it directly, and that absence is the point.
- The research-lane result — no accuracy gap once dark skin is in the training data — rests on the 75.9%-rotted remains of one benchmark, with 93 dark-skin cancers. It is the strongest evidence available on open data. It is not the same thing as a clinical validation, and we will not describe it as one.
- The founder is not a clinician. No clinical claim here has been reviewed by one. That is a gap to be closed by a named medical advisor and a university co-publication, not by confident writing.
Reproduce it
python -m src.data.download_padufes # CC BY 4.0 — commercial lane
python -m src.models.train # ~3 min on an RTX 3060
python -m src.run_audit --reference # the audit above
python -m src.data.fetch_research --research # CC BY-NC-SA — research lane, never ships
python -m src.models.train_research
python -m src.run_audit --predictions artifacts/research/predictions_test.json \
--calibration artifacts/research/predictions_calib.json