An encoder-only transformer that forecasts blood glucose for Type 1 Diabetes.
Python
10
164 commits
updated Oct 4, 2026
An encoder-only transformer that forecasts blood glucose for Type 1 Diabetes. It reads the three signals a phone can actually observe — CGM, the carbohydrate the patient guessed, the insulin they think they took — and returns a fan of seven quantiles over any withheld stretch of glucose, so every prediction carries its own uncertainty. The trailing case is the next two hours.
Trained on synthetic traces from the T1DMSIM behavioural simulator. The trained model exports to ExecuTorch or LiteRT and runs on-device in T1DMDROID.
[!CAUTION] Research and educational use only — not a medical device. T1DMAI is a research artifact. It is not clinically validated, has no regulatory clearance, and its output is a forecast of research signals, not medical advice. It must not be used to make medical, diagnostic, or treatment decisions, to calculate or adjust insulin doses, or to manage diabetes in any way. For medical guidance consult a qualified healthcare professional. The software is provided "as is", without warranty of any kind (see LICENSE).
Six consecutive 5-minute samples become one 30-minute token. A pre-norm transformer stack attends over those tokens with rotary position embeddings and QK-normalisation. Every token is visible or masked. Visible tokens attend among themselves and are blocked from reading a masked one, so no prediction feeds the evidence; a masked token reads everything real. The mask reaches attention as a boolean, per sample, and is not a function of position.
Two heads read the masked tokens, gathered by index into a fixed set of slots. The glucose head runs per 5-minute step rather than per patch. A masked span's tokens, plus the visible token on each side of it where the mask lets the span read one, are the control points of a uniform cubic B-spline; each step's input is that spline read at the step's own position, and one shared MLP maps it to a median offset from the patch's anchor and to six spreads. The input path is twice differentiable across a patch seam, so a seam is not a place the forecast can break. At initialisation the forecast is a flat persistence line. Spreads pass through a softplus and a floor, then accumulate, so the seven quantiles are strictly ordered by construction.
Every masked patch is anchored on the nearest visible reading — the last step to its left, or the first step to its right when the span opens the window — and every patch of one span shares that anchor.
The second head is a time-of-day probe: it classifies each masked patch into one of twelve two-hour bins. There is no clock input, so it has to infer the hour from the trajectory alone. It never touches the forecast, but its gradient does reach the shared trunk, which pushes the same representations the glucose head reads to encode circadian phase.
The model is patient-agnostic. There is no learned per-patient vector; identity is whatever the 84–168 hour context window implies.
Every dimension lives in config.py, and resize_model.py rewrites it.
A window is a run of patches, each visible or masked. A masked patch withholds its glucose and announces that it did, through a bit the model reads; the carbohydrate and insulin channels keep their announced values there as everywhere else. The head emits a quantile fan for every masked patch, and all of them are decoded in one pass.
Where the span sits is the whole difference between the three cases:
| Case | Masked span |
|---|---|
| forecast | ends at the last patch |
| backcast | starts at the first patch |
| infill | anywhere between visible patches |
They are three cases of one objective, not three modes.
Training re-samples the spans per window: between one and MASK_MAX_SPANS of
them, each of a length drawn from MASK_SPAN_LENGTHS, placed over the window
with at least one visible patch between neighbours and a cap on the total masked.
Placement is uniform except for one thing: on MASK_RIGHT_EDGE_QUOTA of windows
the last span is pinned flush against the final patch, so the forecast case is
drawn deliberately rather than left to fall out of uniform placement at about 3 %
of windows. The length law is the same in both branches. There is no curriculum
and no annealing — data.sample_mask_spans is the sampler, and config.py holds
its constants.
A sample off the simulator cache does not draw that branch: its trailing
PREDICTION_PATCHES are always masked, because they are the row's behaviour-off
counterfactual tail. The quota governs the other callers — the real-data windows
and the scoring protocols.
The contract this implements — the masked set, the attention rule, the anchor, the decode — is specified once for the whole suite in T1DMCOMMON/SPEC/inference.md; docs/INFERENCE.md maps it onto this repository.
The model does not forecast mg/dL. It forecasts the Kovatchev risk transform of
mg/dL — a warp of the glucose axis that stretches the hypoglycemic range and
compresses the high one. Here it is re-anchored to the 40–400 mg/dL device range
so that f(40) = −√10 and f(400) = +√10, putting zero risk near 128 mg/dL.
Glucose is clamped to the simulator's physical bounds before the transform, and
those reach further below the low anchor than above the high one, so the realised
risk range is asymmetric.
The consequence is the whole point: the same absolute error is worth several times more loss at 60 mg/dL than at 300. Nothing in the objective mentions hypoglycemia, and no term is focally reweighted — the clinical asymmetry is carried entirely by the geometry the loss is measured in. Glucose enters the model through the same transform, so input, output, target and loss all share one space. Only the reporting layer converts back to mg/dL.
Four features per 5-minute step:
| Feature | Units | Transform |
|---|---|---|
| CGM glucose | mg/dL | Kovatchev f, then z-score |
| carbohydrate | g / step | log1p, then z-score |
| insulin (basal + bolus) | U / step | log1p, then z-score |
| glucose withheld | bit | none |
The carbohydrate is the patient's guessed grams at their guessed glycaemic index, and the insulin is the dose they injected — not what the site delivered.
log1p is near-linear near zero, so the dense basal baseline survives while
rare meal and bolus spikes are compressed into the bulk of the distribution.
The fourth feature is a bit rather than a signal: it is written into every step
column of a masked patch to say that the glucose slot there is blank. It carries
no normalization statistics, so there are three normalized channels behind four
input features. A patch is PATCH_DIM = PATCH_SIZE × N_INPUT_FEATURES values,
laid out step-major.
Insulin sensitivity and hepatic glucose output are simulator latents. A real CGM cannot supply them, so they are deliberately withheld: the model only ever sees what deployment will give it. There are no time-of-day features either.
At a masked patch the glucose slot is blanked — it is what the model predicts — while the carbohydrate and insulin slots carry the meals and doses as their absorption and action curves, per step, in the units above. The model is therefore always conditioned on a declared plan, which is what makes the what-if mode a property of the forward pass rather than a separate mode.
--inputs events on train.py and finetune.py replaces the two curves with
each dose at the step it was taken, ten features per step:
| Feature | Units | Transform |
|---|---|---|
| CGM glucose | mg/dL | Kovatchev f, then z-score |
carb_g, bolus_u, basal_u | g, U, U | log1p, then z-score |
carb_gi | glycaemic index | ln(x / 50) on a dosed step, 0 elsewhere |
bolus_peak_min, basal_peak_min | minutes to peak action | ln(x / 60) on a dosed step, 0 elsewhere |
bolus_dur_h, basal_dur_h | hours of action | ln(x / 5) on a dosed step, 0 elsewhere |
| glucose withheld | bit | none |
Doses sharing a step are summed and their descriptors dose-weighted. A pump's
per-step basal carries its rapid insulin's descriptors; a long-acting injection
carries its own. The channel list is T1DMSIM/simulator.EVENT_CHANNELS. A
checkpoint is stamped with its layout and accepts only that one; the on-device
export covers the curve layout only.
The output is (q_tau, median) in risk space: a seven-level quantile fan at
every 5-minute step of every masked patch, each anchored on its own span's
nearest visible reading. The levels and their ascending order belong to
T1DMCOMMON/SPEC/invariants.md
§6, which fixes them for the whole suite because
every consumer indexes the fan positionally. Inference inverts it to mg/dL.
Batches are simulated on the fly, or drawn from a pre-generated cache. Every window ends at its row's boundary, where the counterfactual tail begins; the context length is re-rolled per sample between 84 and 168 hours, and the masked spans are drawn per sample. Day and night are learned by one model without a band restriction, because each row starts at a random hour, so the boundaries spread over the clock.
The loss has four terms, all in risk space:
(1 − MSE_ALPHA)·DILATE + MSE_ALPHA·MSE. MSE_ALPHA in config.py:
0 is DILATE only, 1 is MSE only.CURVATURE_LAMBDA in config.py. Soft-DTW is warping-invariant,
so it will buy per-step texture the physiology does not have; this term prices
it, against the simulator's own noise-free roughness. It is added outside the
fusion below, so that weight is the whole trade. At the default 0 it is inert.Pinball and the DILATE/MSE slot are fused by learned Kendall-Gal homoscedastic uncertainty weights: two log-variance scalars, trained alongside the model, so the trade-off is learned rather than fixed.
Other details worth knowing:
ndim ≥ 2, the patch-embedding matrix
included; AdamW for the 1-D ones — norm scales and biases — and the two
loss-weighting scalars. Muon's decay on the normalised matrices is
schedule-corrected (AdamC), which removes the end-of-schedule gradient-norm
rise; at peak learning rate it is identical to plain decoupled decay.DETERMINISTIC = True
turns it off.Validation runs every VALIDATION_INTERVAL steps. Every metric is appended to
logs/validation_log.csv, which is the record. The table printed beside it is a
reading surface at that cadence and carries a subset: per-horizon RMSE, MAE and
MARD, band coverage with the width that bought it, median roughness, the
amplitude and excursion-shape block, the in-training conformal probe, all five
zones of both error grids (Clarke and DTS) pooled and per horizon, CG-EGA by
glycemic region, hypo and hyper detection off the band edges both pooled and per
30-minute bucket, the nocturnal pair, time-in-range error, a counterfactual
dose-response probe and the clock probe in full. The
proper scoring rules, the hypo alarm operating
curve, the infill columns and the nocturnal duplicates of the
all-sample metrics are in the CSV and not on the page.
Each validation batch runs three forwards. The selection loss is read off the
sample's own masked set, so the checkpoint is chosen on the validation value of
the training objective. The other two are the
fixed protocols — the horizon-keyed clinical
numbers come from the forecast one, the infill_* columns from the other.
From a cache, the forecast figures are also broken down by the tail arm each
window's horizon came from and by the size of that arm's boundary dose, with the
truth's own roughness printed beside the model's. Beside it sits a paired
reading: over rows whose none arm drops below 70 mg/dL, the share whose carbs
arm does not, once off the truth and once off the model's two median forecasts. A
window with no cache row behind it carries arm -1 and joins no group.
# simulate on the fly
python train.py --master-seed 42 --total-steps 100000 --batch-size 512
# or from a pre-generated pool — see Simulator cache
python train.py --cache-path simulator_cache --total-steps 100000
# two runs side by side, each with its own checkpoints/ and logs/
python train.py --cache-path simulator_cache --run-dir runs/baseline
--max-bolus-only-u U is an experimental training-only draw cap, off by default:
a sample drawing the bolus-only arm from a row whose intended boundary bolus
exceeds U redraws its arm uniformly over the other three. bolus_carbs is not
capped, and the validation and calibration slabs never are, so a capped and an
uncapped run are scored on the same windows.
config.py holds the sampler's constants: the span lengths it draws from
(MASK_SPAN_LENGTHS), the head's slot count and cap on masked patches
(MAX_MASKED_PATCHES), and the share of windows whose last span is pinned flush
against the final patch (MASK_RIGHT_EDGE_QUOTA).
They are bound at import, so they are fixed for a whole run. Every checkpoint stamps them, but nothing reads that stamp back: no parameter shape depends on the sampler, so a checkpoint drawn under a different one loads without complaint. The stamp is a record, not a guard.
All three go through inference.py. docs/INFERENCE.md maps
where each part of the inference contract is implemented here and points at the
suite-wide specification in
T1DMCOMMON, which is what a
non-PyTorch runtime implements against.
Single pass. Given 84–168 hours of context, forecast the next two hours as
seven quantiles per 5-minute step. Upcoming carbohydrate and insulin
can be announced to condition the forecast. mask_spans moves the masked
patches elsewhere — a backcast or an infill — through the same call; the future
patches stay masked whatever else is, since no glucose was ever observed there.
What-if. The same forward pass with a different declared plan written into the masked patches' plan channels — "what happens if I eat 40 g at six". A baseline forecast with no doses is just another call.
Rolling. For horizons past one window, the median forecast is fed back as context and the model runs again. Each quantile level carries its own accumulated offset across a roll boundary and composes it with the next roll's spread in quadrature, so uncertainty grows monotonically instead of resetting at each seam.
Capacity is set by D_MODEL, N_LAYERS and N_HEADS alone: FFN_DIM,
BG_HEAD_HIDDEN and TIME_PROBE_HIDDEN are multiples of D_MODEL and follow
it.
resize_model.py instantiates a capacity and prints its exact trainable
parameter count at PATCH_DIM = PATCH_SIZE × N_INPUT_FEATURES = 24, computed
from the architecture rather than targeted; the model carries no buffers.
Accuracy, wall-clock and peak-memory figures are not listed: none has been
measured against this architecture, and this repository publishes no checkpoint
to re-derive them from. metrics/ scores a locally trained one, and
compare.py tabulates cost and accuracy across a populated models/ tree.
metrics/ scores a checkpoint outside training and reports numbers only.
bash metrics/rebuild_all.sh
Two scoring bases appear throughout and are not interchangeable. The median
line is the genuine point forecast, and the basis every comparison against
published work uses. The band basis scores clip(true, q₂₅, q₇₅), charging
zero error wherever the truth lies inside the band; it describes band geometry,
not point accuracy. Every record names the basis it was measured on.
metrics/scoring.py scores the fan itself, in mg/dL: CRPS, the Winkler interval
score, coverage reported beside the sharpness that bought it, joint coverage over
a whole span as distinct from per-step marginal coverage, and an alarm operating
curve carrying detection rate, false alarms per day and the median lead time in
minutes together.
Everything is binned on d, the distance in patches from a masked patch to the
nearest visible reading on either side — never on span length, which mixes
one-sided and two-sided cases of equal difficulty. Each rule also reports a
pooled figure, which is an average over a mask distribution rather than over a
difficulty and is labelled as not comparable between protocols.
metrics/protocols.py fixes the only two mask placements a run may be compared
on:
| Protocol | Masked | Baseline |
|---|---|---|
| forecast | the trailing span | persistence |
| infill | sampled interior spans | linear interpolation between the bracketing readings |
Infill is never scored against persistence: persistence is a forecasting
baseline, and against a two-sided task it is not a baseline at all. Infill
columns carry their own prefix and a d, so the two protocols cannot be averaged
together by accident.
python metrics/protocols.py # sampler audit and both protocols' d histograms
python calibrate_conformal.py --checkpoint checkpoints/t1dmai_best.pt
python model_health.py --data 128
python validate.py checkpoints/t1dmai_best.pt [--backup record.t1dmbak]
python sim_dose_reference.py --rows 240 --workers 8
python make_figures.py && python make_card.py
calibrate_conformal.py fits a per-step, per-quantile additive band correction
on a disjoint calibration partition and stores it in the checkpoint; it holds
the median fixed and keeps the fan monotone, and must be re-fit per target
distribution. The fit is region-binned (mondrian.py): one correction per bin,
keyed on where the median line is heading rather than on the last reading, since
a fan bound for hypoglycemia and one bound for hyperglycemia leave residuals a
pooled offset gets wrong in both directions. The bin edge sits inside the
euglycemic band and not on a clinical threshold, which would split the windows
that decide an alarm across two separate fits; a bin with too few windows to
support its own extreme order statistic takes the marginal correction instead and
is recorded as having done so. Only the forecast protocol's fit ships in the
band — infill is fitted separately and marked unshipped.
model_health.py audits each architecture knob and the context window for
over-provisioning or saturation and prints the resize_model.py command each
verdict implies. With --data N it streams cached windows through the model and
scores every head, sublayer and block by ablation, the context by truncation and
every width by a keep-top-k ladder, each as the change in pinball loss on the
same windows.
sim_dose_reference.py measures the simulator's own dose response and the
roughness of its noise-free expected tail, and writes them to
sim_dose_reference.json. It deep-copies the simulator at each of 240 cache-row
boundaries, turns patient behaviour off, injects one arm against an undosed twin
that carries identical noise, and divides the 120-minute difference by the same
open-loop formula the validation table's counterfactual probe uses. Every ref
that table prints, and the target on both roughness rows, is read from that file,
so none of them is a written-down number; the rows read ref unknown while the
file is absent. It is checked in, and is regenerated after any change to T1DMSIM.
validate.py prints the training-time validation table for one checkpoint, at
its own architecture, masked-channel policy and normalization statistics.
Without --backup it scores the validation patients training draws for the
checkpoint's master seed, simulated live or read from --cache-path. With
--backup it scores the trailing --test-days of a T1DMDROID backup, the days
t1dmdroid_converter.py holds out; no window crosses a missing reading.
The simulator is a Python loop and starves a fast GPU. The shared generator
pre-builds a pool of post-warmup trajectories once; training then skips the
simulator entirely. This repository ships no cache; training reads a pool built
locally by T1DMSIM/cache_simulator.py. The pools T1DMSIM publishes are earlier
geometries, which data.py rejects at load against the accepted one.
python T1DMSIM/cache_simulator.py --out-dir simulator_cache --pool-size 1000000
python train.py --cache-path simulator_cache --total-steps 100000
# curves plus point events in one pool; either layout trains from it
python T1DMSIM/cache_simulator.py --out-dir simulator_cache --pool-size 1000000 --events
python train.py --inputs events --cache-path simulator_cache --total-steps 100000
--events appends the point-event channels after the curves and fits
normalization_stats_events.json beside normalization_stats.json. The event
layout reads its statistics from the pool, so it requires --cache-path.
A row is 2016 steps of context — 7 days at 5-minute resolution, after a 48 h
warm-up and a random extra offset that lands its boundary on a uniform hour — then
four paired 24-step continuations with the patient's behaviour switched off: one
with nothing at the boundary, one with a bolus, one with carbohydrate, one with
both. A sample takes one arm, so its horizon's announced doses and its ground
truth are the same counterfactual. cache_simulator.py's defaults are that
geometry, and a pool built to any other is rejected at load.
--hypo-oversample weights the pool toward hypoglycemia; it reads the context
alone, and a tail is never rejected. Each pool directory carries a DATASET.md
describing what is in it and the normalization_stats.json fitted on that pool,
over the context and all four tails.
Each channel is a chunked blosc2 array with byte-shuffle and zstd. Byte-shuffle groups the high-entropy mantissa bytes of each float apart from the low-entropy exponent bytes, which gives zstd a far more compressible stream than raw IEEE-754 layout. On a million-row balanced pool that is about 1.3–1.7× on the dense physiologic channels and 27–539× on the near-constant ones — hour-of-day 309×, day index 539× — for roughly 2.4× over the pool as a whole.
A draw varies only the arm and the context width: the window ends at the row's
boundary, so 4 arms × 169 widths is 676 distinct windows per row, and the target
is one of that row's four tails. At 10⁶ rows a 100,000-step run at batch 512
draws each row about 64 times and lands on about 61 of its 676 windows, so
roughly 4.5% of draws repeat a (row, window) pair, and a repeated pair still
draws its own mask. The dataset checks
the cache's meta.json — format, channel list, tail geometry and arm order,
warmup, dt, the
uniform-sample probability — and each channel's shape against the runtime config,
and refuses to train on divergent data. The generation parameters under params
are not checked, so two pools built with different hypoglycemia oversampling are
both accepted.
finetune_data.py merges MetaboNet's train.parquet and test.parquet (and,
unless skipped, the DiaData archive) into one flat cache. Logged meals and doses
become the suite's curves and, beside them, point events; the cache holds
both layouts and both statistics files. It needs pyarrow and pandas.
# <dir> holds train.parquet, test.parquet and, for DiaData, archive.zip
python finetune_data.py build --metabonet-dir <dir> --out <dir>/cache [--skip-diadata]
python finetune_data.py fit-stats --cache <dir>/cache # refit the statistics only
finetune.py trains on the cache's train-period steps and validates on windows
drawn from its test period, printing DTS zone-A share, RMSE and MARD at 30, 60,
90 and 120 minutes, pooled and per sub-dataset. It keeps the checkpoint with the
highest mean zone-A share.
# fine-tune a pretrained checkpoint: architecture, statistics and layout come from it
python finetune.py --checkpoint checkpoints/t1dmai_best.pt --cache <dir>/cache
# train from random initialisation: architecture from config.py, statistics from the cache
python finetune.py --inputs events --cache <dir>/cache --out-dir checkpoints_scratch \
--total-steps 20000 --warmup-steps 1000
python finetune.py --cache <dir>/cache --train-dataset Loop,IOBP2 --test-dataset ReplaceBG
python finetune.py --cache <dir>/cache --iobp2-like
Without --checkpoint the learning rates default to config.py's from-scratch
values; with one they default ten times lower. --inputs must match a
checkpoint's layout. --no-carbs blanks the carbohydrate input. --iobp2-like
trains and validates on the closed-loop sources that log no carbohydrate.
export_submission.py renders the leaderboard's predictions.parquet from a
checkpoint and then scores it. A row's forecast zone opens one step after its
date, so pred_30 is the reading thirty minutes later.
python export_submission.py --checkpoint checkpoints_finetune/finetune_best.pt \
--template template.parquet --cache <dir>/cache --out predictions.parquet --fill ladder
# test.parquet as its own template, thinned, scored per sub-dataset
python export_submission.py --checkpoint <ckpt> --template <dir>/test.parquet --every 400 \
--cache <dir>/cache --out predictions.parquet --fill ladder --by-source
| Flag | Effect |
|---|---|
--fill none | a horizon short of full row coverage is written all-null |
--fill ladder | unpredictable rows take the last reading, then the subject median, then 120 mg/dL |
--future-doses zero | blanks doses over the forecast zone; strictly causal |
--every N | keeps every Nth template row; for scoring, not for a file to submit |
--truth PATH | scores against it; defaults to test.parquet beside --cache, skipped when absent |
score_submission.py scores any predictions file on its own: the median-line DTS
zone-A share, RMSE and MARD per horizon, over the rows whose truth — the CGM
reading nearest date plus the horizon, within half a step — was measured.
python score_submission.py predictions.parquet --truth <dir>/test.parquet --by-source
resize_model.py rewrites config.py from explicit override flags. With no
flags it prints the current architecture and parameter count and exits.
python resize_model.py # inspect
python resize_model.py --d-model 192 --heads 3 # head_dim 64
python resize_model.py --layers 12
python resize_model.py --d-model 256 --heads 4 --report-only # preview only
It instantiates the candidate on the meta device to count parameters — the
count is computed from the architecture, never targeted — and prints a
before → after diff. It refuses to write unless HEAD_DIM ∈ {16, 32, 64, 128}
(so attention dispatches a fused kernel instead of materialising the full T×T
matrix) and an integer number of patches tiles the hour.
exporters/ turns a checkpoint into a runtime artifact plus a descriptor. The
engine-agnostic parts — the modified forward, checkpoint loading, the descriptor
emitter — are shared; each engine module owns only its own lowering. Each writes
its artifact and then checks it on host against the eager forward, exiting
non-zero if the delta exceeds tolerance — so a failed export still leaves the
artifact on disk. XNNPACK and LiteRT run the lowered artifact itself; the pip
ExecuTorch runtime carries no Vulkan backend, so that path checks the exported
graph through a portable-CPU lowering and gates the GPU numerics on-device
against the fp32 reference.
| Engine | Module | Emits |
|---|---|---|
| ExecuTorch XNNPACK (CPU fp32) | exporters.executorch_xnnpack | .xnnpack.pte + descriptor |
| LiteRT (NPU path) | exporters.litert_npu --fp16 | .tflite + descriptor |
| ExecuTorch Vulkan (GPU) | exporters.executorch_vulkan --write-pte --fp16 | .vulkan.pte + descriptor |
Run them as modules from the repository root. All three take --checkpoint,
--model-id and --out-dir. CPU fp32 is the
reference every other engine is measured against; the Vulkan module also reports
how much of the graph the backend delegates versus falls back to CPU.
./deploy_model.sh <checkpoint> [model-id] runs the XNNPACK export and pushes
the artifact, head file and descriptor to an attached T1DMDROID phone over adb.
The exported graph takes the masked set as an input: a one-hot matrix naming the
patch each head slot reads, at one fixed sequence length, with the attention mask
arriving as an additive float built outside the graph. Forecast, backcast and
infill are the same artifact under different inputs. --seq-len exports a
shorter window as a cheaper artifact with a shorter memory. The contract is in
T1DMCOMMON/SPEC/inference.md.
The graph is cut at the raw head output and also emits the trunk hidden state for every patch, which — with the head weights written beside the artifact — lets a consumer rebuild the step states, re-run the head, and adapt it without re-exporting. The anchor, the softplus and floor, the inverse transform and the quantile assembly all run outside it — and the descriptor is the sole contract for that pre- and post-processing. An artifact and its descriptor are one unit: a graph served against a descriptor from a different architecture decodes risk space with the wrong constants, and nothing downstream can detect it.
There are two environments. requirements.txt is the training one — training,
evaluation, the GUI and the tests — at torch>=2.4 on Python 3.10 or newer.
requirements-export.txt is .venv-export, which exporters/ and
deploy_model.sh run under: it pins executorch to the version the consuming
runtime bundles (1.3.1, which requires torch>=2.12 and publishes wheels for
CPython 3.10–3.13 only) together with torch, torchao, numpy and blosc2 at
the versions that resolve against it. Those bounds conflict with the training
environment's, so neither file lists the other's packages. The LiteRT path adds
litert-torch, which neither file pins.
python3.11 -m venv .venv-export
.venv-export/bin/pip install -r requirements-export.txt
python gui.py --seed 42
python gui.py --checkpoint checkpoints/t1dmai_best.pt --seed 42
A pygame front end for inspecting a checkpoint one patient at a time: the median
forecast with its quantile envelope, a draggable cursor, and a curve editor for
painting meals and boluses into the forecast span and re-forecasting against
them. F rolls the forecast forward one horizon, G steps the simulator, N
draws a new patient, V scores the current forecast against the simulator.
Predictions run on a background thread. --no-model starts with random weights
for UI work.
With no --checkpoint the GUI takes the one capacity under models/ whose
architecture matches the live config.py — the only one that could load anyway.
The simulated patient runs out to MAX_CONTEXT_PATCHES, so the model is fed the
window it was trained on; --context-hours shortens it, and anything under
MIN_CONTEXT_PATCHES is refused rather than forecast out of distribution. The
chart opens on the trailing 24 hours of that window: scroll to zoom, arrow keys
or a middle/right-drag to pan — Shift with an arrow moves a full screen — and
R to return. The viewport is a view — every patch is fed to
the forward whatever is on screen.
M opens the masking tool, which drags patch-aligned spans over the context and
draws one quantile fan per span in place. Forecast, begin-fill and infill are
presets of the same masked-BG objective — a span at the right edge, a span at
patch 0, a span between visible patches — and a free-form span sits anywhere
else. The trailing forecast span is always part of the masked set: the future
zone carries no observed reading, so it is never left visible, and the head's
MAX_MASKED_PATCHES slots are shared between it and the drawn spans. Masked
spans clear whenever the context changes.
T opens a strip of heat rows under the chart, on the chart's own time axis,
showing what the selected masked span read. The top row is attention over the
window's patches, composed across the layers by rollout and drawn on a log scale
in multiples of an even share — the row spans decades, so a linear ramp renders
everything below a share identically black, which is most of the window. The
composition's residual term concentrates mass on the query's own patches, the
more so the shallower the stack; [ and ] step the row through the individual
layers, which carry no such term, and back to the composed one. , and . step which forward is explained: a rolling
forecast records one per roll, and only roll 0 reads a context that is entirely
observed — every later roll attends to one partly built from the model's own
output. Attention
runs over patches alone — the patch embedding mixes the four features into one
token before the first block — so the rows below it are gradient rather than
attention: the signed grad ⊙ input per channel, red where an input raises the
span's forecast and blue where it lowers it, on one scale across the three
channels with each channel's share of the total at the right. A masked span's
median is its anchor plus a delta, so the BG row carries the anchor's own term
at the one context cell it was read from. Over a masked patch that row is marked
withheld rather than drawn as zero: the builder writes a literal 0.0 into feat 0
there, and a product with a zero input is zero whatever the gradient — the model
still conditions on the patch, through the bit that announces it. The maps are computed alongside the
forecast when the strip is open, and off the forecast already on screen when the
strip is opened after one; selecting another masked span, or stepping to another
roll, re-aims them without re-forecasting. A new masked set, dose or context
needs a new prediction. Ink is scaled to the patches on screen rather than to the
whole window, so panning does not black the rows out; the percentages are over
the whole window and do not move with the view.
The GUI reads the checkpoint's masked_channel_policy and shows it. Under the
blind policy the masked spans carry the no-dose fill rather than the recorded
carb / insulin, matching how that checkpoint was trained, and the
dose-painting controls are disabled.
Python 3.10 or newer.
git clone https://github.com/0xdeadf1sh/T1DMSIM ../T1DMSIM
ln -s ../T1DMSIM T1DMSIM
pip install -r requirements.txt
The T1DMSIM symlink is required by anything that imports the model: model.py
and utils.py read the physical glucose bounds from it, so training, inference,
evaluation and export all need it in place. Only the exported artifact and its
descriptor are self-contained — the on-device runtime never sees the simulator.
Core dependencies are torch, numpy and blosc2; figures and the GUI add
matplotlib and pygame; tests add pytest. This repository bundles no patient
data and reads none.
# normalization statistics (writes normalization_stats.json)
python normalization.py
# train on simulator data
python train.py --master-seed 42 --total-steps 100000
# smoke-test a checkpoint against a fresh simulator patient
python inference.py --checkpoint checkpoints/t1dmai_best.pt --use-ema
python -m pytest tests/ -v
metrics/rebuild_all.sh writes its own report alongside the JSON and figures it
produces; that tree is generated rather than checked in.
The figures in this README come from python make_readme_figures.py, which needs
a checkpoint for the masked-BG panels — pass --skip-forecast for the diagrams
alone.
Risk space and clinical accuracy
Objective
Architecture and optimisation
Calibration
Copyright 2026 0xdeadf1sh. MIT License — see LICENSE.
An encoder-only transformer that forecasts blood glucose for Type 1 Diabetes.
Python
10
164 commits
updated Oct 4, 2026
An encoder-only transformer that forecasts blood glucose for Type 1 Diabetes. It reads the three signals a phone can actually observe — CGM, the carbohydrate the patient guessed, the insulin they think they took — and returns a fan of seven quantiles over any withheld stretch of glucose, so every prediction carries its own uncertainty. The trailing case is the next two hours.
Trained on synthetic traces from the T1DMSIM behavioural simulator. The trained model exports to ExecuTorch or LiteRT and runs on-device in T1DMDROID.
[!CAUTION] Research and educational use only — not a medical device. T1DMAI is a research artifact. It is not clinically validated, has no regulatory clearance, and its output is a forecast of research signals, not medical advice. It must not be used to make medical, diagnostic, or treatment decisions, to calculate or adjust insulin doses, or to manage diabetes in any way. For medical guidance consult a qualified healthcare professional. The software is provided "as is", without warranty of any kind (see LICENSE).
Six consecutive 5-minute samples become one 30-minute token. A pre-norm transformer stack attends over those tokens with rotary position embeddings and QK-normalisation. Every token is visible or masked. Visible tokens attend among themselves and are blocked from reading a masked one, so no prediction feeds the evidence; a masked token reads everything real. The mask reaches attention as a boolean, per sample, and is not a function of position.
Two heads read the masked tokens, gathered by index into a fixed set of slots. The glucose head runs per 5-minute step rather than per patch. A masked span's tokens, plus the visible token on each side of it where the mask lets the span read one, are the control points of a uniform cubic B-spline; each step's input is that spline read at the step's own position, and one shared MLP maps it to a median offset from the patch's anchor and to six spreads. The input path is twice differentiable across a patch seam, so a seam is not a place the forecast can break. At initialisation the forecast is a flat persistence line. Spreads pass through a softplus and a floor, then accumulate, so the seven quantiles are strictly ordered by construction.
Every masked patch is anchored on the nearest visible reading — the last step to its left, or the first step to its right when the span opens the window — and every patch of one span shares that anchor.
The second head is a time-of-day probe: it classifies each masked patch into one of twelve two-hour bins. There is no clock input, so it has to infer the hour from the trajectory alone. It never touches the forecast, but its gradient does reach the shared trunk, which pushes the same representations the glucose head reads to encode circadian phase.
The model is patient-agnostic. There is no learned per-patient vector; identity is whatever the 84–168 hour context window implies.
Every dimension lives in config.py, and resize_model.py rewrites it.
A window is a run of patches, each visible or masked. A masked patch withholds its glucose and announces that it did, through a bit the model reads; the carbohydrate and insulin channels keep their announced values there as everywhere else. The head emits a quantile fan for every masked patch, and all of them are decoded in one pass.
Where the span sits is the whole difference between the three cases:
| Case | Masked span |
|---|---|
| forecast | ends at the last patch |
| backcast | starts at the first patch |
| infill | anywhere between visible patches |
They are three cases of one objective, not three modes.
Training re-samples the spans per window: between one and MASK_MAX_SPANS of
them, each of a length drawn from MASK_SPAN_LENGTHS, placed over the window
with at least one visible patch between neighbours and a cap on the total masked.
Placement is uniform except for one thing: on MASK_RIGHT_EDGE_QUOTA of windows
the last span is pinned flush against the final patch, so the forecast case is
drawn deliberately rather than left to fall out of uniform placement at about 3 %
of windows. The length law is the same in both branches. There is no curriculum
and no annealing — data.sample_mask_spans is the sampler, and config.py holds
its constants.
A sample off the simulator cache does not draw that branch: its trailing
PREDICTION_PATCHES are always masked, because they are the row's behaviour-off
counterfactual tail. The quota governs the other callers — the real-data windows
and the scoring protocols.
The contract this implements — the masked set, the attention rule, the anchor, the decode — is specified once for the whole suite in T1DMCOMMON/SPEC/inference.md; docs/INFERENCE.md maps it onto this repository.
The model does not forecast mg/dL. It forecasts the Kovatchev risk transform of
mg/dL — a warp of the glucose axis that stretches the hypoglycemic range and
compresses the high one. Here it is re-anchored to the 40–400 mg/dL device range
so that f(40) = −√10 and f(400) = +√10, putting zero risk near 128 mg/dL.
Glucose is clamped to the simulator's physical bounds before the transform, and
those reach further below the low anchor than above the high one, so the realised
risk range is asymmetric.
The consequence is the whole point: the same absolute error is worth several times more loss at 60 mg/dL than at 300. Nothing in the objective mentions hypoglycemia, and no term is focally reweighted — the clinical asymmetry is carried entirely by the geometry the loss is measured in. Glucose enters the model through the same transform, so input, output, target and loss all share one space. Only the reporting layer converts back to mg/dL.
Four features per 5-minute step:
| Feature | Units | Transform |
|---|---|---|
| CGM glucose | mg/dL | Kovatchev f, then z-score |
| carbohydrate | g / step | log1p, then z-score |
| insulin (basal + bolus) | U / step | log1p, then z-score |
| glucose withheld | bit | none |
The carbohydrate is the patient's guessed grams at their guessed glycaemic index, and the insulin is the dose they injected — not what the site delivered.
log1p is near-linear near zero, so the dense basal baseline survives while
rare meal and bolus spikes are compressed into the bulk of the distribution.
The fourth feature is a bit rather than a signal: it is written into every step
column of a masked patch to say that the glucose slot there is blank. It carries
no normalization statistics, so there are three normalized channels behind four
input features. A patch is PATCH_DIM = PATCH_SIZE × N_INPUT_FEATURES values,
laid out step-major.
Insulin sensitivity and hepatic glucose output are simulator latents. A real CGM cannot supply them, so they are deliberately withheld: the model only ever sees what deployment will give it. There are no time-of-day features either.
At a masked patch the glucose slot is blanked — it is what the model predicts — while the carbohydrate and insulin slots carry the meals and doses as their absorption and action curves, per step, in the units above. The model is therefore always conditioned on a declared plan, which is what makes the what-if mode a property of the forward pass rather than a separate mode.
--inputs events on train.py and finetune.py replaces the two curves with
each dose at the step it was taken, ten features per step:
| Feature | Units | Transform |
|---|---|---|
| CGM glucose | mg/dL | Kovatchev f, then z-score |
carb_g, bolus_u, basal_u | g, U, U | log1p, then z-score |
carb_gi | glycaemic index | ln(x / 50) on a dosed step, 0 elsewhere |
bolus_peak_min, basal_peak_min | minutes to peak action | ln(x / 60) on a dosed step, 0 elsewhere |
bolus_dur_h, basal_dur_h | hours of action | ln(x / 5) on a dosed step, 0 elsewhere |
| glucose withheld | bit | none |
Doses sharing a step are summed and their descriptors dose-weighted. A pump's
per-step basal carries its rapid insulin's descriptors; a long-acting injection
carries its own. The channel list is T1DMSIM/simulator.EVENT_CHANNELS. A
checkpoint is stamped with its layout and accepts only that one; the on-device
export covers the curve layout only.
The output is (q_tau, median) in risk space: a seven-level quantile fan at
every 5-minute step of every masked patch, each anchored on its own span's
nearest visible reading. The levels and their ascending order belong to
T1DMCOMMON/SPEC/invariants.md
§6, which fixes them for the whole suite because
every consumer indexes the fan positionally. Inference inverts it to mg/dL.
Batches are simulated on the fly, or drawn from a pre-generated cache. Every window ends at its row's boundary, where the counterfactual tail begins; the context length is re-rolled per sample between 84 and 168 hours, and the masked spans are drawn per sample. Day and night are learned by one model without a band restriction, because each row starts at a random hour, so the boundaries spread over the clock.
The loss has four terms, all in risk space:
(1 − MSE_ALPHA)·DILATE + MSE_ALPHA·MSE. MSE_ALPHA in config.py:
0 is DILATE only, 1 is MSE only.CURVATURE_LAMBDA in config.py. Soft-DTW is warping-invariant,
so it will buy per-step texture the physiology does not have; this term prices
it, against the simulator's own noise-free roughness. It is added outside the
fusion below, so that weight is the whole trade. At the default 0 it is inert.Pinball and the DILATE/MSE slot are fused by learned Kendall-Gal homoscedastic uncertainty weights: two log-variance scalars, trained alongside the model, so the trade-off is learned rather than fixed.
Other details worth knowing:
ndim ≥ 2, the patch-embedding matrix
included; AdamW for the 1-D ones — norm scales and biases — and the two
loss-weighting scalars. Muon's decay on the normalised matrices is
schedule-corrected (AdamC), which removes the end-of-schedule gradient-norm
rise; at peak learning rate it is identical to plain decoupled decay.DETERMINISTIC = True
turns it off.Validation runs every VALIDATION_INTERVAL steps. Every metric is appended to
logs/validation_log.csv, which is the record. The table printed beside it is a
reading surface at that cadence and carries a subset: per-horizon RMSE, MAE and
MARD, band coverage with the width that bought it, median roughness, the
amplitude and excursion-shape block, the in-training conformal probe, all five
zones of both error grids (Clarke and DTS) pooled and per horizon, CG-EGA by
glycemic region, hypo and hyper detection off the band edges both pooled and per
30-minute bucket, the nocturnal pair, time-in-range error, a counterfactual
dose-response probe and the clock probe in full. The
proper scoring rules, the hypo alarm operating
curve, the infill columns and the nocturnal duplicates of the
all-sample metrics are in the CSV and not on the page.
Each validation batch runs three forwards. The selection loss is read off the
sample's own masked set, so the checkpoint is chosen on the validation value of
the training objective. The other two are the
fixed protocols — the horizon-keyed clinical
numbers come from the forecast one, the infill_* columns from the other.
From a cache, the forecast figures are also broken down by the tail arm each
window's horizon came from and by the size of that arm's boundary dose, with the
truth's own roughness printed beside the model's. Beside it sits a paired
reading: over rows whose none arm drops below 70 mg/dL, the share whose carbs
arm does not, once off the truth and once off the model's two median forecasts. A
window with no cache row behind it carries arm -1 and joins no group.
# simulate on the fly
python train.py --master-seed 42 --total-steps 100000 --batch-size 512
# or from a pre-generated pool — see Simulator cache
python train.py --cache-path simulator_cache --total-steps 100000
# two runs side by side, each with its own checkpoints/ and logs/
python train.py --cache-path simulator_cache --run-dir runs/baseline
--max-bolus-only-u U is an experimental training-only draw cap, off by default:
a sample drawing the bolus-only arm from a row whose intended boundary bolus
exceeds U redraws its arm uniformly over the other three. bolus_carbs is not
capped, and the validation and calibration slabs never are, so a capped and an
uncapped run are scored on the same windows.
config.py holds the sampler's constants: the span lengths it draws from
(MASK_SPAN_LENGTHS), the head's slot count and cap on masked patches
(MAX_MASKED_PATCHES), and the share of windows whose last span is pinned flush
against the final patch (MASK_RIGHT_EDGE_QUOTA).
They are bound at import, so they are fixed for a whole run. Every checkpoint stamps them, but nothing reads that stamp back: no parameter shape depends on the sampler, so a checkpoint drawn under a different one loads without complaint. The stamp is a record, not a guard.
All three go through inference.py. docs/INFERENCE.md maps
where each part of the inference contract is implemented here and points at the
suite-wide specification in
T1DMCOMMON, which is what a
non-PyTorch runtime implements against.
Single pass. Given 84–168 hours of context, forecast the next two hours as
seven quantiles per 5-minute step. Upcoming carbohydrate and insulin
can be announced to condition the forecast. mask_spans moves the masked
patches elsewhere — a backcast or an infill — through the same call; the future
patches stay masked whatever else is, since no glucose was ever observed there.
What-if. The same forward pass with a different declared plan written into the masked patches' plan channels — "what happens if I eat 40 g at six". A baseline forecast with no doses is just another call.
Rolling. For horizons past one window, the median forecast is fed back as context and the model runs again. Each quantile level carries its own accumulated offset across a roll boundary and composes it with the next roll's spread in quadrature, so uncertainty grows monotonically instead of resetting at each seam.
Capacity is set by D_MODEL, N_LAYERS and N_HEADS alone: FFN_DIM,
BG_HEAD_HIDDEN and TIME_PROBE_HIDDEN are multiples of D_MODEL and follow
it.
resize_model.py instantiates a capacity and prints its exact trainable
parameter count at PATCH_DIM = PATCH_SIZE × N_INPUT_FEATURES = 24, computed
from the architecture rather than targeted; the model carries no buffers.
Accuracy, wall-clock and peak-memory figures are not listed: none has been
measured against this architecture, and this repository publishes no checkpoint
to re-derive them from. metrics/ scores a locally trained one, and
compare.py tabulates cost and accuracy across a populated models/ tree.
metrics/ scores a checkpoint outside training and reports numbers only.
bash metrics/rebuild_all.sh
Two scoring bases appear throughout and are not interchangeable. The median
line is the genuine point forecast, and the basis every comparison against
published work uses. The band basis scores clip(true, q₂₅, q₇₅), charging
zero error wherever the truth lies inside the band; it describes band geometry,
not point accuracy. Every record names the basis it was measured on.
metrics/scoring.py scores the fan itself, in mg/dL: CRPS, the Winkler interval
score, coverage reported beside the sharpness that bought it, joint coverage over
a whole span as distinct from per-step marginal coverage, and an alarm operating
curve carrying detection rate, false alarms per day and the median lead time in
minutes together.
Everything is binned on d, the distance in patches from a masked patch to the
nearest visible reading on either side — never on span length, which mixes
one-sided and two-sided cases of equal difficulty. Each rule also reports a
pooled figure, which is an average over a mask distribution rather than over a
difficulty and is labelled as not comparable between protocols.
metrics/protocols.py fixes the only two mask placements a run may be compared
on:
| Protocol | Masked | Baseline |
|---|---|---|
| forecast | the trailing span | persistence |
| infill | sampled interior spans | linear interpolation between the bracketing readings |
Infill is never scored against persistence: persistence is a forecasting
baseline, and against a two-sided task it is not a baseline at all. Infill
columns carry their own prefix and a d, so the two protocols cannot be averaged
together by accident.
python metrics/protocols.py # sampler audit and both protocols' d histograms
python calibrate_conformal.py --checkpoint checkpoints/t1dmai_best.pt
python model_health.py --data 128
python validate.py checkpoints/t1dmai_best.pt [--backup record.t1dmbak]
python sim_dose_reference.py --rows 240 --workers 8
python make_figures.py && python make_card.py
calibrate_conformal.py fits a per-step, per-quantile additive band correction
on a disjoint calibration partition and stores it in the checkpoint; it holds
the median fixed and keeps the fan monotone, and must be re-fit per target
distribution. The fit is region-binned (mondrian.py): one correction per bin,
keyed on where the median line is heading rather than on the last reading, since
a fan bound for hypoglycemia and one bound for hyperglycemia leave residuals a
pooled offset gets wrong in both directions. The bin edge sits inside the
euglycemic band and not on a clinical threshold, which would split the windows
that decide an alarm across two separate fits; a bin with too few windows to
support its own extreme order statistic takes the marginal correction instead and
is recorded as having done so. Only the forecast protocol's fit ships in the
band — infill is fitted separately and marked unshipped.
model_health.py audits each architecture knob and the context window for
over-provisioning or saturation and prints the resize_model.py command each
verdict implies. With --data N it streams cached windows through the model and
scores every head, sublayer and block by ablation, the context by truncation and
every width by a keep-top-k ladder, each as the change in pinball loss on the
same windows.
sim_dose_reference.py measures the simulator's own dose response and the
roughness of its noise-free expected tail, and writes them to
sim_dose_reference.json. It deep-copies the simulator at each of 240 cache-row
boundaries, turns patient behaviour off, injects one arm against an undosed twin
that carries identical noise, and divides the 120-minute difference by the same
open-loop formula the validation table's counterfactual probe uses. Every ref
that table prints, and the target on both roughness rows, is read from that file,
so none of them is a written-down number; the rows read ref unknown while the
file is absent. It is checked in, and is regenerated after any change to T1DMSIM.
validate.py prints the training-time validation table for one checkpoint, at
its own architecture, masked-channel policy and normalization statistics.
Without --backup it scores the validation patients training draws for the
checkpoint's master seed, simulated live or read from --cache-path. With
--backup it scores the trailing --test-days of a T1DMDROID backup, the days
t1dmdroid_converter.py holds out; no window crosses a missing reading.
The simulator is a Python loop and starves a fast GPU. The shared generator
pre-builds a pool of post-warmup trajectories once; training then skips the
simulator entirely. This repository ships no cache; training reads a pool built
locally by T1DMSIM/cache_simulator.py. The pools T1DMSIM publishes are earlier
geometries, which data.py rejects at load against the accepted one.
python T1DMSIM/cache_simulator.py --out-dir simulator_cache --pool-size 1000000
python train.py --cache-path simulator_cache --total-steps 100000
# curves plus point events in one pool; either layout trains from it
python T1DMSIM/cache_simulator.py --out-dir simulator_cache --pool-size 1000000 --events
python train.py --inputs events --cache-path simulator_cache --total-steps 100000
--events appends the point-event channels after the curves and fits
normalization_stats_events.json beside normalization_stats.json. The event
layout reads its statistics from the pool, so it requires --cache-path.
A row is 2016 steps of context — 7 days at 5-minute resolution, after a 48 h
warm-up and a random extra offset that lands its boundary on a uniform hour — then
four paired 24-step continuations with the patient's behaviour switched off: one
with nothing at the boundary, one with a bolus, one with carbohydrate, one with
both. A sample takes one arm, so its horizon's announced doses and its ground
truth are the same counterfactual. cache_simulator.py's defaults are that
geometry, and a pool built to any other is rejected at load.
--hypo-oversample weights the pool toward hypoglycemia; it reads the context
alone, and a tail is never rejected. Each pool directory carries a DATASET.md
describing what is in it and the normalization_stats.json fitted on that pool,
over the context and all four tails.
Each channel is a chunked blosc2 array with byte-shuffle and zstd. Byte-shuffle groups the high-entropy mantissa bytes of each float apart from the low-entropy exponent bytes, which gives zstd a far more compressible stream than raw IEEE-754 layout. On a million-row balanced pool that is about 1.3–1.7× on the dense physiologic channels and 27–539× on the near-constant ones — hour-of-day 309×, day index 539× — for roughly 2.4× over the pool as a whole.
A draw varies only the arm and the context width: the window ends at the row's
boundary, so 4 arms × 169 widths is 676 distinct windows per row, and the target
is one of that row's four tails. At 10⁶ rows a 100,000-step run at batch 512
draws each row about 64 times and lands on about 61 of its 676 windows, so
roughly 4.5% of draws repeat a (row, window) pair, and a repeated pair still
draws its own mask. The dataset checks
the cache's meta.json — format, channel list, tail geometry and arm order,
warmup, dt, the
uniform-sample probability — and each channel's shape against the runtime config,
and refuses to train on divergent data. The generation parameters under params
are not checked, so two pools built with different hypoglycemia oversampling are
both accepted.
finetune_data.py merges MetaboNet's train.parquet and test.parquet (and,
unless skipped, the DiaData archive) into one flat cache. Logged meals and doses
become the suite's curves and, beside them, point events; the cache holds
both layouts and both statistics files. It needs pyarrow and pandas.
# <dir> holds train.parquet, test.parquet and, for DiaData, archive.zip
python finetune_data.py build --metabonet-dir <dir> --out <dir>/cache [--skip-diadata]
python finetune_data.py fit-stats --cache <dir>/cache # refit the statistics only
finetune.py trains on the cache's train-period steps and validates on windows
drawn from its test period, printing DTS zone-A share, RMSE and MARD at 30, 60,
90 and 120 minutes, pooled and per sub-dataset. It keeps the checkpoint with the
highest mean zone-A share.
# fine-tune a pretrained checkpoint: architecture, statistics and layout come from it
python finetune.py --checkpoint checkpoints/t1dmai_best.pt --cache <dir>/cache
# train from random initialisation: architecture from config.py, statistics from the cache
python finetune.py --inputs events --cache <dir>/cache --out-dir checkpoints_scratch \
--total-steps 20000 --warmup-steps 1000
python finetune.py --cache <dir>/cache --train-dataset Loop,IOBP2 --test-dataset ReplaceBG
python finetune.py --cache <dir>/cache --iobp2-like
Without --checkpoint the learning rates default to config.py's from-scratch
values; with one they default ten times lower. --inputs must match a
checkpoint's layout. --no-carbs blanks the carbohydrate input. --iobp2-like
trains and validates on the closed-loop sources that log no carbohydrate.
export_submission.py renders the leaderboard's predictions.parquet from a
checkpoint and then scores it. A row's forecast zone opens one step after its
date, so pred_30 is the reading thirty minutes later.
python export_submission.py --checkpoint checkpoints_finetune/finetune_best.pt \
--template template.parquet --cache <dir>/cache --out predictions.parquet --fill ladder
# test.parquet as its own template, thinned, scored per sub-dataset
python export_submission.py --checkpoint <ckpt> --template <dir>/test.parquet --every 400 \
--cache <dir>/cache --out predictions.parquet --fill ladder --by-source
| Flag | Effect |
|---|---|
--fill none | a horizon short of full row coverage is written all-null |
--fill ladder | unpredictable rows take the last reading, then the subject median, then 120 mg/dL |
--future-doses zero | blanks doses over the forecast zone; strictly causal |
--every N | keeps every Nth template row; for scoring, not for a file to submit |
--truth PATH | scores against it; defaults to test.parquet beside --cache, skipped when absent |
score_submission.py scores any predictions file on its own: the median-line DTS
zone-A share, RMSE and MARD per horizon, over the rows whose truth — the CGM
reading nearest date plus the horizon, within half a step — was measured.
python score_submission.py predictions.parquet --truth <dir>/test.parquet --by-source
resize_model.py rewrites config.py from explicit override flags. With no
flags it prints the current architecture and parameter count and exits.
python resize_model.py # inspect
python resize_model.py --d-model 192 --heads 3 # head_dim 64
python resize_model.py --layers 12
python resize_model.py --d-model 256 --heads 4 --report-only # preview only
It instantiates the candidate on the meta device to count parameters — the
count is computed from the architecture, never targeted — and prints a
before → after diff. It refuses to write unless HEAD_DIM ∈ {16, 32, 64, 128}
(so attention dispatches a fused kernel instead of materialising the full T×T
matrix) and an integer number of patches tiles the hour.
exporters/ turns a checkpoint into a runtime artifact plus a descriptor. The
engine-agnostic parts — the modified forward, checkpoint loading, the descriptor
emitter — are shared; each engine module owns only its own lowering. Each writes
its artifact and then checks it on host against the eager forward, exiting
non-zero if the delta exceeds tolerance — so a failed export still leaves the
artifact on disk. XNNPACK and LiteRT run the lowered artifact itself; the pip
ExecuTorch runtime carries no Vulkan backend, so that path checks the exported
graph through a portable-CPU lowering and gates the GPU numerics on-device
against the fp32 reference.
| Engine | Module | Emits |
|---|---|---|
| ExecuTorch XNNPACK (CPU fp32) | exporters.executorch_xnnpack | .xnnpack.pte + descriptor |
| LiteRT (NPU path) | exporters.litert_npu --fp16 | .tflite + descriptor |
| ExecuTorch Vulkan (GPU) | exporters.executorch_vulkan --write-pte --fp16 | .vulkan.pte + descriptor |
Run them as modules from the repository root. All three take --checkpoint,
--model-id and --out-dir. CPU fp32 is the
reference every other engine is measured against; the Vulkan module also reports
how much of the graph the backend delegates versus falls back to CPU.
./deploy_model.sh <checkpoint> [model-id] runs the XNNPACK export and pushes
the artifact, head file and descriptor to an attached T1DMDROID phone over adb.
The exported graph takes the masked set as an input: a one-hot matrix naming the
patch each head slot reads, at one fixed sequence length, with the attention mask
arriving as an additive float built outside the graph. Forecast, backcast and
infill are the same artifact under different inputs. --seq-len exports a
shorter window as a cheaper artifact with a shorter memory. The contract is in
T1DMCOMMON/SPEC/inference.md.
The graph is cut at the raw head output and also emits the trunk hidden state for every patch, which — with the head weights written beside the artifact — lets a consumer rebuild the step states, re-run the head, and adapt it without re-exporting. The anchor, the softplus and floor, the inverse transform and the quantile assembly all run outside it — and the descriptor is the sole contract for that pre- and post-processing. An artifact and its descriptor are one unit: a graph served against a descriptor from a different architecture decodes risk space with the wrong constants, and nothing downstream can detect it.
There are two environments. requirements.txt is the training one — training,
evaluation, the GUI and the tests — at torch>=2.4 on Python 3.10 or newer.
requirements-export.txt is .venv-export, which exporters/ and
deploy_model.sh run under: it pins executorch to the version the consuming
runtime bundles (1.3.1, which requires torch>=2.12 and publishes wheels for
CPython 3.10–3.13 only) together with torch, torchao, numpy and blosc2 at
the versions that resolve against it. Those bounds conflict with the training
environment's, so neither file lists the other's packages. The LiteRT path adds
litert-torch, which neither file pins.
python3.11 -m venv .venv-export
.venv-export/bin/pip install -r requirements-export.txt
python gui.py --seed 42
python gui.py --checkpoint checkpoints/t1dmai_best.pt --seed 42
A pygame front end for inspecting a checkpoint one patient at a time: the median
forecast with its quantile envelope, a draggable cursor, and a curve editor for
painting meals and boluses into the forecast span and re-forecasting against
them. F rolls the forecast forward one horizon, G steps the simulator, N
draws a new patient, V scores the current forecast against the simulator.
Predictions run on a background thread. --no-model starts with random weights
for UI work.
With no --checkpoint the GUI takes the one capacity under models/ whose
architecture matches the live config.py — the only one that could load anyway.
The simulated patient runs out to MAX_CONTEXT_PATCHES, so the model is fed the
window it was trained on; --context-hours shortens it, and anything under
MIN_CONTEXT_PATCHES is refused rather than forecast out of distribution. The
chart opens on the trailing 24 hours of that window: scroll to zoom, arrow keys
or a middle/right-drag to pan — Shift with an arrow moves a full screen — and
R to return. The viewport is a view — every patch is fed to
the forward whatever is on screen.
M opens the masking tool, which drags patch-aligned spans over the context and
draws one quantile fan per span in place. Forecast, begin-fill and infill are
presets of the same masked-BG objective — a span at the right edge, a span at
patch 0, a span between visible patches — and a free-form span sits anywhere
else. The trailing forecast span is always part of the masked set: the future
zone carries no observed reading, so it is never left visible, and the head's
MAX_MASKED_PATCHES slots are shared between it and the drawn spans. Masked
spans clear whenever the context changes.
T opens a strip of heat rows under the chart, on the chart's own time axis,
showing what the selected masked span read. The top row is attention over the
window's patches, composed across the layers by rollout and drawn on a log scale
in multiples of an even share — the row spans decades, so a linear ramp renders
everything below a share identically black, which is most of the window. The
composition's residual term concentrates mass on the query's own patches, the
more so the shallower the stack; [ and ] step the row through the individual
layers, which carry no such term, and back to the composed one. , and . step which forward is explained: a rolling
forecast records one per roll, and only roll 0 reads a context that is entirely
observed — every later roll attends to one partly built from the model's own
output. Attention
runs over patches alone — the patch embedding mixes the four features into one
token before the first block — so the rows below it are gradient rather than
attention: the signed grad ⊙ input per channel, red where an input raises the
span's forecast and blue where it lowers it, on one scale across the three
channels with each channel's share of the total at the right. A masked span's
median is its anchor plus a delta, so the BG row carries the anchor's own term
at the one context cell it was read from. Over a masked patch that row is marked
withheld rather than drawn as zero: the builder writes a literal 0.0 into feat 0
there, and a product with a zero input is zero whatever the gradient — the model
still conditions on the patch, through the bit that announces it. The maps are computed alongside the
forecast when the strip is open, and off the forecast already on screen when the
strip is opened after one; selecting another masked span, or stepping to another
roll, re-aims them without re-forecasting. A new masked set, dose or context
needs a new prediction. Ink is scaled to the patches on screen rather than to the
whole window, so panning does not black the rows out; the percentages are over
the whole window and do not move with the view.
The GUI reads the checkpoint's masked_channel_policy and shows it. Under the
blind policy the masked spans carry the no-dose fill rather than the recorded
carb / insulin, matching how that checkpoint was trained, and the
dose-painting controls are disabled.
Python 3.10 or newer.
git clone https://github.com/0xdeadf1sh/T1DMSIM ../T1DMSIM
ln -s ../T1DMSIM T1DMSIM
pip install -r requirements.txt
The T1DMSIM symlink is required by anything that imports the model: model.py
and utils.py read the physical glucose bounds from it, so training, inference,
evaluation and export all need it in place. Only the exported artifact and its
descriptor are self-contained — the on-device runtime never sees the simulator.
Core dependencies are torch, numpy and blosc2; figures and the GUI add
matplotlib and pygame; tests add pytest. This repository bundles no patient
data and reads none.
# normalization statistics (writes normalization_stats.json)
python normalization.py
# train on simulator data
python train.py --master-seed 42 --total-steps 100000
# smoke-test a checkpoint against a fresh simulator patient
python inference.py --checkpoint checkpoints/t1dmai_best.pt --use-ema
python -m pytest tests/ -v
metrics/rebuild_all.sh writes its own report alongside the JSON and figures it
produces; that tree is generated rather than checked in.
The figures in this README come from python make_readme_figures.py, which needs
a checkpoint for the masked-BG panels — pass --skip-forecast for the diagrams
alone.
Risk space and clinical accuracy
Objective
Architecture and optimisation
Calibration
Copyright 2026 0xdeadf1sh. MIT License — see LICENSE.