0xdeadf1sh/T1DMAI

An encoder-only transformer that forecasts blood glucose for Type 1 Diabetes.

Python

10

164 commits

updated Oct 4, 2026

See the code

See what people are saying

SourceMessageScoreDate

I have trained a model to predict my blood sugar (Part 2) [P] (r/MachineLearning)

This is related to my [previous post](https://www.reddit.com/r/MachineLearning/comments/1vc1txc/i_have_trained_a_model_to_predict_my_blood_sugar_p/) where I shared an [encoder-only transformer model](https://github.com/0xdeadf1sh/T1DMAI) trained on ohiot1dm + shanghait1dm + azt1d datasets. This…

0

Oct 5, 2026

README

T1DMAI

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).

Backcast, infill and forecast spans with their quantile bands

Table of contents

Architecture

Architecture diagram

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.

Masked-BG objective

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:

CaseMasked span
forecastends at the last patch
backcaststarts at the first patch
infillanywhere 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.

Risk space

The Kovatchev risk transform and the loss asymmetry it creates

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.

Inputs and outputs

Four features per 5-minute step:

FeatureUnitsTransform
CGM glucosemg/dLKovatchev f, then z-score
carbohydrateg / steplog1p, then z-score
insulin (basal + bolus)U / steplog1p, then z-score
glucose withheldbitnone

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.

Point-event layout

--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:

FeatureUnitsTransform
CGM glucosemg/dLKovatchev f, then z-score
carb_g, bolus_u, basal_ug, U, Ulog1p, then z-score
carb_giglycaemic indexln(x / 50) on a dosed step, 0 elsewhere
bolus_peak_min, basal_peak_minminutes to peak actionln(x / 60) on a dosed step, 0 elsewhere
bolus_dur_h, basal_dur_hhours of actionln(x / 5) on a dosed step, 0 elsewhere
glucose withheldbitnone

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.

Training

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:

  • Pinball loss over all seven quantile levels, which calibrates each level to its coverage and pins the median pointwise.
  • DILATE on the median — a shape term built from a soft dynamic-time-warping divergence plus a temporal-distortion term, so a forecast that predicts the right excursion a step early is not punished as though it predicted the wrong excursion. The temporal term is evaluated as a directional derivative of the soft-DTW value, which avoids materialising the alignment matrix.
  • MSE on the median, in risk space, sharing DILATE's slot as (1 − MSE_ALPHA)·DILATE + MSE_ALPHA·MSE. MSE_ALPHA in config.py: 0 is DILATE only, 1 is MSE only.
  • Curvature on the median — its mean squared second difference per span, weighted by 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:

  • Muon for every parameter with 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.
  • Weight EMA for evaluation. Threshold-crossing metrics like hypo recall are sensitive to small parameter jitter, so validation runs under a shadow copy and training continues on the live weights.
  • Raw signals throughout. There is no smoother on the inputs or on the target. The same raw post-noise glucose is the input, the target and the anchor, so train and deployment distributions match by construction.
  • fp32 everywhere. No autocast, no bf16 or fp16, no gradient checkpointing — though the default CUDA path leaves TF32 matmul on; DETERMINISTIC = True turns it off.
  • Disjoint partitions. In cache mode, train, validation and conformal-calibration draws are carved into non-overlapping slabs of the pool, so a held-out seed cannot reproject onto a training row. On the fly, the seed bands are separated by construction.

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.

The mask sampler

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.

Inference modes

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

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.

Scoring rules and protocols

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:

ProtocolMaskedBaseline
forecastthe trailing spanpersistence
infillsampled interior spanslinear 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

Other tools

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.

Simulator cache

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.

Real-world data: MetaboNet

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
FlagEffect
--fill nonea horizon short of full row coverage is written all-null
--fill ladderunpredictable rows take the last reading, then the subject median, then 120 mg/dL
--future-doses zeroblanks doses over the forecast zone; strictly causal
--every Nkeeps every Nth template row; for scoring, not for a file to submit
--truth PATHscores 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

Resizing the model

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.

Exporting for on-device inference

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.

EngineModuleEmits
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

Interactive GUI

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.

Installation

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.

Quick start

# 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

Testing

python -m pytest tests/ -v

Documentation

  • ARCHITECTURE.md — the full model, loss and training specification, block by block.
  • docs/INFERENCE.md — where each inference concern is implemented here, and a pointer to the suite-wide specification.
  • docs/COMPARISON.md — the per-capacity checkpoint layout and the cross-model comparison.

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.

References

Risk space and clinical accuracy

  • Kovatchev, B. P., Cox, D. J., Gonder-Frederick, L. A., and Clarke, W. Symmetrization of the blood glucose measurement scale and its applications. Diabetes Care 20(11), 1655–1658 (1997). doi:10.2337/diacare.20.11.1655 — the symmetrizing transform this model forecasts in.
  • Kovatchev, B. P., Gonder-Frederick, L. A., Cox, D. J., and Clarke, W. L. Evaluating the accuracy of continuous glucose-monitoring sensors: continuous glucose-error grid analysis illustrated by TheraSense Freestyle Navigator data. Diabetes Care 27(8), 1922–1928 (2004). doi:10.2337/diacare.27.8.1922 — CG-EGA, adapted here to prediction following the dotXem/CG-EGA reference implementation.
  • Clarke, W. L., Cox, D., Gonder-Frederick, L. A., Carter, W., and Pohl, S. L. Evaluating clinical accuracy of systems for self-monitoring of blood glucose. Diabetes Care 10(5), 622–628 (1987). doi:10.2337/diacare.10.5.622 — the Clarke Error Grid.
  • Klonoff, D. C., Freckmann, G., Pleus, S., Kovatchev, B. P., Kerr, D., et al. The Diabetes Technology Society Error Grid and Trend Accuracy Matrix for Glucose Monitors. Journal of Diabetes Science and Technology 18(6), 1346–1361 (2024). doi:10.1177/19322968241275701 — the DTS Error Grid, whose five zones are contours of the risk function fitted to the Surveillance Error Grid. The grid is in the public domain. It is defined for glucose monitors, where reference and monitor are simultaneous; scoring a forecast against the truth at its horizon is the same substitution made here for Clarke, and is not a use the source sanctions.
  • Sivananthan, S., Naumova, V., Dalla Man, C., Facchinetti, A., Renard, E., Cobelli, C., and Pereverzyev, S. V. Assessment of blood glucose predictors: the prediction-error grid analysis. Diabetes Technology & Therapeutics 13(8), 787–796 (2011). doi:10.1089/dia.2011.0033 — PRED-EGA, the grid built for predictors rather than monitors. Not implemented here.

Objective

  • Le Guen, V., and Thome, N. Shape and Time Distortion Loss for Training Deep Time Series Forecasting Models. NeurIPS 2019. arXiv:1909.09020 — DILATE.
  • Cuturi, M., and Blondel, M. Soft-DTW: a Differentiable Loss Function for Time-Series. ICML 2017. arXiv:1703.01541.
  • Blondel, M., Mensch, A., and Vert, J.-P. Differentiable Divergences Between Time Series. AISTATS 2021. arXiv:2010.08354 — the soft-DTW divergence form, which is zero at a perfect match.
  • Kendall, A., Gal, Y., and Cipolla, R. Multi-Task Learning Using Uncertainty to Weigh Losses for Scene Geometry and Semantics. CVPR 2018. arXiv:1705.07115 — the learned loss weighting.
  • Koenker, R., and Bassett, G. Regression Quantiles. Econometrica 46(1), 33–50 (1978). doi:10.2307/1913643 — the pinball loss.

Architecture and optimisation

  • Nie, Y., Nguyen, N. H., Sinthong, P., and Kalagnanam, J. A Time Series is Worth 64 Words: Long-term Forecasting with Transformers. ICLR 2023. arXiv:2211.14730 — patch-token time-series transformers.
  • Su, J., Lu, Y., Pan, S., Murtadha, A., Wen, B., and Liu, Y. RoFormer: Enhanced Transformer with Rotary Position Embedding. Neurocomputing 568 (2024). arXiv:2104.09864 — RoPE.
  • Shazeer, N. GLU Variants Improve Transformer. arXiv:2002.05202 (2020) — SwiGLU.
  • Zhang, B., and Sennrich, R. Root Mean Square Layer Normalization. NeurIPS 2019. arXiv:1910.07467 — RMSNorm.
  • Dehghani, M., Djolonga, J., Mustafa, B., et al. Scaling Vision Transformers to 22 Billion Parameters. ICML 2023. arXiv:2302.05442 — QK-normalisation.
  • Jordan, K., Jin, Y., Boza, V., You, J., Cesista, F., Newhouse, L., and Bernstein, J. Muon: An optimizer for hidden layers in neural networks. (2024). https://kellerjordan.github.io/posts/muon/
  • Defazio, A. Why Gradients Rapidly Increase Near the End of Training. arXiv:2506.02285 (2025) — the AdamC schedule-aware weight-decay correction.
  • Loshchilov, I., and Hutter, F. Decoupled Weight Decay Regularization. ICLR 2019. arXiv:1711.05101 — AdamW.
  • Abnar, S., and Zuidema, W. Quantifying Attention Flow in Transformers. ACL 2020. arXiv:2005.00928 — attention rollout, the layer composition behind the GUI's attention strip.

Calibration

  • Vovk, V., Gammerman, A., and Shafer, G. Algorithmic Learning in a Random World. Springer (2005) — conformal prediction.
  • Romano, Y., Patterson, E., and Candès, E. J. Conformalized Quantile Regression. NeurIPS 2019. arXiv:1905.03222 — the split-conformal band recalibration used here.
  • T1DMSIM — the behavioural simulator that generates this model's pretraining corpus.
  • T1DMDROID — the Android app that runs the exported artifact on-device against a live CGM feed.
  • T1DMCOMMON — the shared specification the three projects are built against.

License

Copyright 2026 0xdeadf1sh. MIT License — see LICENSE.

0xdeadf1sh/T1DMAI

An encoder-only transformer that forecasts blood glucose for Type 1 Diabetes.

Python

10

164 commits

updated Oct 4, 2026

See the code

See what people are saying

SourceMessageScoreDate

I have trained a model to predict my blood sugar (Part 2) [P] (r/MachineLearning)

This is related to my [previous post](https://www.reddit.com/r/MachineLearning/comments/1vc1txc/i_have_trained_a_model_to_predict_my_blood_sugar_p/) where I shared an [encoder-only transformer model](https://github.com/0xdeadf1sh/T1DMAI) trained on ohiot1dm + shanghait1dm + azt1d datasets. This…

0

Oct 5, 2026

README

T1DMAI

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).

Backcast, infill and forecast spans with their quantile bands

Table of contents

Architecture

Architecture diagram

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.

Masked-BG objective

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:

CaseMasked span
forecastends at the last patch
backcaststarts at the first patch
infillanywhere 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.

Risk space

The Kovatchev risk transform and the loss asymmetry it creates

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.

Inputs and outputs

Four features per 5-minute step:

FeatureUnitsTransform
CGM glucosemg/dLKovatchev f, then z-score
carbohydrateg / steplog1p, then z-score
insulin (basal + bolus)U / steplog1p, then z-score
glucose withheldbitnone

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.

Point-event layout

--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:

FeatureUnitsTransform
CGM glucosemg/dLKovatchev f, then z-score
carb_g, bolus_u, basal_ug, U, Ulog1p, then z-score
carb_giglycaemic indexln(x / 50) on a dosed step, 0 elsewhere
bolus_peak_min, basal_peak_minminutes to peak actionln(x / 60) on a dosed step, 0 elsewhere
bolus_dur_h, basal_dur_hhours of actionln(x / 5) on a dosed step, 0 elsewhere
glucose withheldbitnone

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.

Training

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:

  • Pinball loss over all seven quantile levels, which calibrates each level to its coverage and pins the median pointwise.
  • DILATE on the median — a shape term built from a soft dynamic-time-warping divergence plus a temporal-distortion term, so a forecast that predicts the right excursion a step early is not punished as though it predicted the wrong excursion. The temporal term is evaluated as a directional derivative of the soft-DTW value, which avoids materialising the alignment matrix.
  • MSE on the median, in risk space, sharing DILATE's slot as (1 − MSE_ALPHA)·DILATE + MSE_ALPHA·MSE. MSE_ALPHA in config.py: 0 is DILATE only, 1 is MSE only.
  • Curvature on the median — its mean squared second difference per span, weighted by 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:

  • Muon for every parameter with 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.
  • Weight EMA for evaluation. Threshold-crossing metrics like hypo recall are sensitive to small parameter jitter, so validation runs under a shadow copy and training continues on the live weights.
  • Raw signals throughout. There is no smoother on the inputs or on the target. The same raw post-noise glucose is the input, the target and the anchor, so train and deployment distributions match by construction.
  • fp32 everywhere. No autocast, no bf16 or fp16, no gradient checkpointing — though the default CUDA path leaves TF32 matmul on; DETERMINISTIC = True turns it off.
  • Disjoint partitions. In cache mode, train, validation and conformal-calibration draws are carved into non-overlapping slabs of the pool, so a held-out seed cannot reproject onto a training row. On the fly, the seed bands are separated by construction.

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.

The mask sampler

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.

Inference modes

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

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.

Scoring rules and protocols

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:

ProtocolMaskedBaseline
forecastthe trailing spanpersistence
infillsampled interior spanslinear 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

Other tools

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.

Simulator cache

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.

Real-world data: MetaboNet

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
FlagEffect
--fill nonea horizon short of full row coverage is written all-null
--fill ladderunpredictable rows take the last reading, then the subject median, then 120 mg/dL
--future-doses zeroblanks doses over the forecast zone; strictly causal
--every Nkeeps every Nth template row; for scoring, not for a file to submit
--truth PATHscores 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

Resizing the model

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.

Exporting for on-device inference

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.

EngineModuleEmits
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

Interactive GUI

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.

Installation

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.

Quick start

# 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

Testing

python -m pytest tests/ -v

Documentation

  • ARCHITECTURE.md — the full model, loss and training specification, block by block.
  • docs/INFERENCE.md — where each inference concern is implemented here, and a pointer to the suite-wide specification.
  • docs/COMPARISON.md — the per-capacity checkpoint layout and the cross-model comparison.

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.

References

Risk space and clinical accuracy

  • Kovatchev, B. P., Cox, D. J., Gonder-Frederick, L. A., and Clarke, W. Symmetrization of the blood glucose measurement scale and its applications. Diabetes Care 20(11), 1655–1658 (1997). doi:10.2337/diacare.20.11.1655 — the symmetrizing transform this model forecasts in.
  • Kovatchev, B. P., Gonder-Frederick, L. A., Cox, D. J., and Clarke, W. L. Evaluating the accuracy of continuous glucose-monitoring sensors: continuous glucose-error grid analysis illustrated by TheraSense Freestyle Navigator data. Diabetes Care 27(8), 1922–1928 (2004). doi:10.2337/diacare.27.8.1922 — CG-EGA, adapted here to prediction following the dotXem/CG-EGA reference implementation.
  • Clarke, W. L., Cox, D., Gonder-Frederick, L. A., Carter, W., and Pohl, S. L. Evaluating clinical accuracy of systems for self-monitoring of blood glucose. Diabetes Care 10(5), 622–628 (1987). doi:10.2337/diacare.10.5.622 — the Clarke Error Grid.
  • Klonoff, D. C., Freckmann, G., Pleus, S., Kovatchev, B. P., Kerr, D., et al. The Diabetes Technology Society Error Grid and Trend Accuracy Matrix for Glucose Monitors. Journal of Diabetes Science and Technology 18(6), 1346–1361 (2024). doi:10.1177/19322968241275701 — the DTS Error Grid, whose five zones are contours of the risk function fitted to the Surveillance Error Grid. The grid is in the public domain. It is defined for glucose monitors, where reference and monitor are simultaneous; scoring a forecast against the truth at its horizon is the same substitution made here for Clarke, and is not a use the source sanctions.
  • Sivananthan, S., Naumova, V., Dalla Man, C., Facchinetti, A., Renard, E., Cobelli, C., and Pereverzyev, S. V. Assessment of blood glucose predictors: the prediction-error grid analysis. Diabetes Technology & Therapeutics 13(8), 787–796 (2011). doi:10.1089/dia.2011.0033 — PRED-EGA, the grid built for predictors rather than monitors. Not implemented here.

Objective

  • Le Guen, V., and Thome, N. Shape and Time Distortion Loss for Training Deep Time Series Forecasting Models. NeurIPS 2019. arXiv:1909.09020 — DILATE.
  • Cuturi, M., and Blondel, M. Soft-DTW: a Differentiable Loss Function for Time-Series. ICML 2017. arXiv:1703.01541.
  • Blondel, M., Mensch, A., and Vert, J.-P. Differentiable Divergences Between Time Series. AISTATS 2021. arXiv:2010.08354 — the soft-DTW divergence form, which is zero at a perfect match.
  • Kendall, A., Gal, Y., and Cipolla, R. Multi-Task Learning Using Uncertainty to Weigh Losses for Scene Geometry and Semantics. CVPR 2018. arXiv:1705.07115 — the learned loss weighting.
  • Koenker, R., and Bassett, G. Regression Quantiles. Econometrica 46(1), 33–50 (1978). doi:10.2307/1913643 — the pinball loss.

Architecture and optimisation

  • Nie, Y., Nguyen, N. H., Sinthong, P., and Kalagnanam, J. A Time Series is Worth 64 Words: Long-term Forecasting with Transformers. ICLR 2023. arXiv:2211.14730 — patch-token time-series transformers.
  • Su, J., Lu, Y., Pan, S., Murtadha, A., Wen, B., and Liu, Y. RoFormer: Enhanced Transformer with Rotary Position Embedding. Neurocomputing 568 (2024). arXiv:2104.09864 — RoPE.
  • Shazeer, N. GLU Variants Improve Transformer. arXiv:2002.05202 (2020) — SwiGLU.
  • Zhang, B., and Sennrich, R. Root Mean Square Layer Normalization. NeurIPS 2019. arXiv:1910.07467 — RMSNorm.
  • Dehghani, M., Djolonga, J., Mustafa, B., et al. Scaling Vision Transformers to 22 Billion Parameters. ICML 2023. arXiv:2302.05442 — QK-normalisation.
  • Jordan, K., Jin, Y., Boza, V., You, J., Cesista, F., Newhouse, L., and Bernstein, J. Muon: An optimizer for hidden layers in neural networks. (2024). https://kellerjordan.github.io/posts/muon/
  • Defazio, A. Why Gradients Rapidly Increase Near the End of Training. arXiv:2506.02285 (2025) — the AdamC schedule-aware weight-decay correction.
  • Loshchilov, I., and Hutter, F. Decoupled Weight Decay Regularization. ICLR 2019. arXiv:1711.05101 — AdamW.
  • Abnar, S., and Zuidema, W. Quantifying Attention Flow in Transformers. ACL 2020. arXiv:2005.00928 — attention rollout, the layer composition behind the GUI's attention strip.

Calibration

  • Vovk, V., Gammerman, A., and Shafer, G. Algorithmic Learning in a Random World. Springer (2005) — conformal prediction.
  • Romano, Y., Patterson, E., and Candès, E. J. Conformalized Quantile Regression. NeurIPS 2019. arXiv:1905.03222 — the split-conformal band recalibration used here.
  • T1DMSIM — the behavioural simulator that generates this model's pretraining corpus.
  • T1DMDROID — the Android app that runs the exported artifact on-device against a live CGM feed.
  • T1DMCOMMON — the shared specification the three projects are built against.

License

Copyright 2026 0xdeadf1sh. MIT License — see LICENSE.