Skip to content

Repository files navigation

serving-skew-sentinel

Training and serving disagree about a feature. This says which side is wrong, why, and how many decisions it changed.

Training and serving skew is not a modelling problem. It is two implementations of the same definition, written by different people at different times against different stores, drifting apart quietly. The model keeps scoring. The offline metric keeps looking fine. Nothing fails.

A parity check finds the difference. It cannot tell you what to do about it, for three reasons this project is built around:

  1. Parity is symmetric. It says the two paths differ. It cannot say which one deviates from what was intended, because neither path is a reference. So this tool implements the declared semantics a third time and arbitrates a sample against it, which turns 13 named causes out of 16 into 16 out of 16, with a blamed side on 12 of them.
  2. A difference is not an incident. One scenario here differs on 90.2% of requests and changes one decision out of 1,200. Another differs on 9.2% and changes 106. Severity is measured in decisions, at a declared threshold, not in mismatch counts.
  3. Skew is not drift. Same dashboard, opposite responses: skew needs a code change and drift may need a retrain, and retraining on a skewed training set makes the skew permanent. Both are computed and the quadrant is named.

Fully offline, no API keys, one seed, deterministic. pytest fits a real scikit-learn model, replays every injected skew through both pipelines, and re-derives every number in this file.

A replay of the CLI naming each injected
  skew, blaming a side, and costing it in decisions.
A replay of real command output. Every line is the actual stdout of the command shown above it, paced by that command's measured wall time. Source: tools/record_demo.py. There is an MP4 too.


The 60-second version

$ sentinel audit --scenario clean --labels
verdict:  parity   (scenario clean)
requests: 1,200   features disagreeing: 0 of 9
decisions:     0 changed (0.00%)   0 up, 0 down   threshold 0.4608
auc:      training 0.8112   serving 0.8112   gap +0.0000
referee:  200 row(s)   drift: agree
ok:       every feature agrees within its declared tolerance

The control, and the correct output is silence. Now one feature arrives in milliseconds where the spec declared seconds:

$ sentinel audit --scenario unit_seconds_ms --labels
verdict:  skew   (scenario unit_seconds_ms)
requests: 1,200   features disagreeing: 1 of 9
decisions:    59 changed (4.92%)   0 up, 59 down   threshold 0.4608
auc:      training 0.8112   serving 0.5535   gap +0.2577
referee:  200 row(s)   drift: skew_only
[x] unit_mismatch (serving deviates): seconds_since_last_txn differs by a constant factor of 1000 on
    100.0% of requests: milliseconds where seconds were expected
blame:    seconds_since_last_txn carries -0.13960 of the -0.13960 mean score gap (exact Shapley)
$ echo $?
1

One feature, one factor, and 0.2577 AUC: a model that discriminated at 0.81 offline is discriminating at 0.55 in production. The cause is named, the deviating side is named, and the cost is in decisions.


Why a third implementation

Two faults produce identical evidence under any pairwise comparison:

  • the training pipeline computes a seven-day window that reaches past the decision instant, so it has seen the future;
  • the serving pipeline computes a three-day window where seven was intended.

Both make training's values larger than serving's, on the same three window aggregates, by a similar amount. One is a leak that inflates the offline metric and cannot be fixed at serving. The other is a serving defect that costs almost nothing today. Different owner, different fix, different urgency.

Turn arbitration off and the tool says what a parity checker can say:

$ sentinel audit --scenario time_travel_leak --referee-sample 0
[x] unexplained_skew: txn_count_7d disagrees on 88.7% of requests in a shape none of the known
    causes describes
[x] unexplained_skew: amount_sum_7d disagrees on 88.7% of requests in a shape none of the known
    causes describes
[x] unexplained_skew: amount_max_7d disagrees on 50.7% of requests in a shape none of the known
    causes describes

True, and useless. Turn it on:

$ sentinel audit --scenario time_travel_leak --labels
auc:      training 0.845   serving 0.8112   gap +0.0338
[x] time_travel_leak (training deviates): 3 window aggregate(s) are larger in training than serving
    can produce, up to 88.7% of requests, and the training path is the side that deviates from the
    declared semantics

referee.py implements the spec's semantics directly and slowly, one request at a time, no vectorisation, no shared code with either production path. Deliberately: a fast referee would share code and therefore share bugs with the path it is judging.

causes named as declared side blamed
without the referee 13 of 16 0
with the referee 16 of 16 12

It costs about 20x the serving path, roughly 0.24 s against 0.01 s for 200 requests, on a sample rather than on the stream. Those seconds are wall-clock and move between machines, so the multiple is quoted to the nearest five and the raw figures live in docs/experiments/referee_value.json. 10 arbitrated rows are enough to separate the confusable pair; the default of 200 exists because the report also prints per-feature agreement rates and a rate over ten rows reads as noise. Full argument in ADR-001.


What it detects

16 scenarios, 14 causes, all replayed through the real pipelines by experiments/skew_matrix.py and by the test suite, which run the same code path. Three of the scenarios expect silence and get it.

skew cause named deviating side worst mismatch decisions changed AUC gap
no skew (control) nothing (correct) n/a 0.0% 0 (0.00%) +0.0000
seconds against milliseconds unit_mismatch serving 100.0% 59 (4.92%) +0.2577
the training set saw the future time_travel_leak training 88.7% 21 (1.75%) +0.0338
the serving window is shorter window_mismatch serving 67.6% 4 (0.33%) +0.0013
the serving store is fifteen minutes behind stale_features serving 2.0% 0 (0.00%) +0.0000
the fill value is applied on one side only default_value_mismatch serving 9.2% 3 (0.25%) +0.0015
a sentinel instead of a missing value sentinel_value serving 9.2% 106 (8.83%) +0.0495
the winsorisation is applied on one side only transform_missing serving 8.1% 41 (3.42%) +0.0060
two defaults for one failed lookup lookup_default_mismatch serving 1.8% 0 (0.00%) +0.0024
rounded crossing a serialisation boundary rounding_at_boundary serving 90.2% 1 (0.08%) +0.0001
two feature slots filled in the wrong order feature_slot_swap serving 99.8% 105 (8.75%) +0.2296
float32 in the serving runtime nothing (correct) n/a 0.0% 0 (0.00%) +0.0000
the same float32, judged with no tolerance precision_loss serving 98.2% 0 (0.00%) +0.0000
a category the vocabulary has never seen unseen_category n/a 0.0% 0 (0.00%) +0.0000
the world moved and both paths agree nothing (correct) n/a 0.0% 0 (0.00%) +0.0000
the world moved and the paths disagree unit_mismatch serving 100.0% 94 (7.83%) +0.2398

Two rows deserve reading together. rounding_at_boundary differs on 90.2% of requests and changes one decision. sentinel_value differs on 9.2% and changes 106. The relationship between how often two values differ and how much damage that does is not monotone in either direction, which is why severity is measured in decisions (ADR-005).

The two causes with no scenario are unexplained_skew, the honest fallback for a shape no rule describes, and population_drift, which the drift half reports rather than the classifier. Both are covered by hand-built evidence in the unit tests, and tests/test_scenarios.py asserts that those two and no others are missing, so a cause added without an injector fails the build.

A unit mismatch: the verdict, the tiles, the named cause with the blamed side, and the skew-or-drift quadrant


Severity is decisions, not differences

$ sentinel audit --scenario json_rounding --labels    # 90.2% of requests differ
decisions:     1 changed (0.08%)
[!] rounding_at_boundary (serving deviates): device_score is quantised to 4 decimal place(s) at
    serving and not in training, on 90.2% of requests; 1 decision(s) change
$ echo $?
0

$ sentinel audit --scenario sentinel_999 --labels     # 9.2% of requests differ
decisions:   106 changed (8.83%)
[x] sentinel_value (serving deviates): device_score is -999 on 111 serving row(s), outside the range
    the training path ever produces
$ echo $?
1

Same tool, opposite conclusions, and the mismatch rates point the other way. Exit codes are the interface, so they are fixed and tested through the installed command, three runs per case: 0 nothing blocking, 1 a blocking finding, 2 a bad invocation, 3 a side could not be built. --fail-on {none,warning,blocking} chooses the gate, changes the exit code, and never changes the output.

9% of requests differ and 106 decisions change: the report, the quadrant, and every feature


Skew or drift

The same population change, twice: once with a defect and once without.

$ sentinel audit --scenario population_drift --labels
referee:  200 row(s)   drift: drift_only
ok:       every feature agrees within its declared tolerance

$ sentinel audit --scenario drift_and_skew --labels
referee:  200 row(s)   drift: both
[x] unit_mismatch (serving deviates): seconds_since_last_txn differs by a constant factor of 1000

Skew is a paired comparison, same entity and same instant through two implementations. Drift is unpaired, one implementation across two time windows. They are different measurements of different things and they get the same dashboard, so this tool computes both and names the quadrant with the action:

  • skew only: a code defect. Fix the pipeline and do not retrain, because a retrain fits the model to the bug.
  • drift only: nothing is broken. Whether it needs a retrain is a question about labels.
  • both: fix parity first. A shared upstream change can move both paths together, which drift sees and a parity check cannot, so this is the case where a parity-only tool reports "no change" during a real incident.

The drift verdict is correct in 16 of 16 scenarios. Getting there caught a bug in the fixture rather than in the tool: the control scenario reported drift because the injected incident process started when the request stream started, so accumulating features ramped across it and amount_sum_7d reached a population stability index of 0.117. The cheap fix was to raise the threshold. The right one was to give the incident process a history. Written up in ADR-002.

The quadrant, the per-feature parity chart, the score-gap histogram and the attribution chart


Which feature moved the score

$ sentinel explain --scenario slot_swap
mean score gap +0.019138 over 300 sampled request(s)

feature                     exact shapley  one at a time   difference
country_risk                    +0.013922      +0.000267    -0.013656
device_score                    +0.005216      -0.008440    -0.013656

exact sums to +0.019138, which is the whole gap by construction
one at a time sums to -0.008173, missing it by 0.027311
the two methods disagree about the top feature: country_risk against device_score

The cheap way to attribute a score gap is one feature at a time: swap one serving value into the training vector, rescore, call the difference that feature's contribution. It is what most parity dashboards do and the parts do not add up, because the score is a sigmoid of a linear combination.

This computes Shapley values exactly, over all 2**k subsets, and it is affordable because of one structural fact: the logit of a hybrid vector is the training logit plus the sum of the per-feature logit deltas taken from serving, so each subset is one addition rather than one forward pass. Above 16 differing features the tool refuses rather than switching silently to a sampled approximation and printing it under the same heading.

Measured across the 13 scenarios with a score gap: exact Shapley sums to the whole gap in every one, the two methods agree on the top feature in 12 of 13, and the worst relative additivity error is 142.7%. On slot_swap the cheap method gets the sign of the entire gap backwards, because a slot swap is nothing but an interaction and a single-swap method cannot see one. ADR-003.


The spec is the third input

Neither pipeline contains the facts a parity check needs. specs/fraud.yaml does, and it is reviewed like code:

threshold: 0.460767

semantics:
  window_days: 7
  as_of: true              # [T - 7 days, T). Strictly before T.
  clip_amount_to: 25000    # both paths, or neither
  missing_device_score: 0.0
  unknown_country_risk: 0.0
  time_unit: seconds
  no_history_seconds: 2592000

features:
  - name: seconds_since_last_txn
    unit: seconds
    tolerance: 1.0         # two clocks will not agree more closely than a second
  - name: country_risk
    unit: score
    tolerance: 1.0e-6      # absorbs float32 and nothing that means anything

Tolerance zero is not the strict choice. A serving runtime that casts to float32 is a correct serving runtime, and under exact comparison it is flagged on 98% of requests. An alert that fires on 98% of requests where nothing is wrong is muted within a week, and a muted alert cannot catch the real thing either. The sweep in experiments/tolerance_cost.py finds a workable band of 1e-07 to 0.1, seven orders of magnitude wide, with the real bug caught at every tolerance in it. ADR-004.

Units are declared and never converted. A feature declared in seconds and delivered in milliseconds is a finding, not something to normalise away.


Install and use

pip install -e ".[dev]"
pytest                                  # 219 tests, fits a real model, replays every scenario
make demo                               # the whole CLI against 11 scenarios, asserting exit codes
sentinel audit     --scenario unit_seconds_ms --labels     # parity, impact, arbitration, drift, blame
sentinel parity    --scenario unit_seconds_ms              # per-feature agreement, the cheap question
sentinel arbitrate --scenario short_window                 # which side deviates, feature by feature
sentinel explain   --scenario slot_swap                    # attribute the gap, exactly and cheaply

Every command takes --json. audit also writes --out (JSON), --markdown (for a pull request comment) and --html (one self-contained file for an incident ticket, no external asset of any kind).

Pointing it at a real deployment means replacing skews/ with the two implementations under audit and writing a spec. The scenario registry lives outside the package for exactly that reason: it is a fixture, not library code, and cli.py imports it lazily so the boundary is visible.


Receipts

tests 219 tests, 9 files, no network, one seed
coverage 95.9% line, 88.5% branch
scenarios 16 injected, 14 declared causes, 3 expecting silence
experiments 5, each writing JSON that the README is checked against
ADRs 5, each naming the experiment that measures it

Nothing above is typed by hand. tools/collect_metrics.py re-runs the suite and all five experiments and writes docs/metrics.json; tools/check_numbers.py fails the build when a document quotes a number that is no longer measured, or when the README quotes a number nothing measures at all.

The checker is anchored rather than digit-matching. A metric declares the wording that must surround it, so "{} arbitrated rows" at 10 requires the phrase, not a bare 10 somewhere on the page. The digit-only version of this tool shipped in an earlier project and passed while three claims were false, because the sentence around the number had changed meaning.

$ python tools/check_numbers.py
ok: 32 anchored phrase(s) and 13 value(s) checked across 7 document(s)

CI runs lint, the suite with coverage, the demo with its exit-code assertions, the metric collection, and the number check, on every push.


Design decisions

Two mistakes are documented rather than quietly fixed, because they are the parts of this repository that took the longest to understand.

A leak that cost nothing. The first version of experiments/leakage_cost.py measured a time-travel leak worth 0.002 AUC, and the conclusion sitting in the draft was "leakage is overrated". It was not a finding about leakage. It was a finding about a synthetic world where the future window carried the same information as the past one. Real leakage is expensive because the future contains the consequence of the label: a card used fraudulently gets used again, hard, within hours. Once inject_consequences put that in the world, the leaky model's offline number overstated what serving delivers by 0.0874 AUC: 0.8853 on its own features and 0.7979 on the same serving features the honest model scores 0.8112 on, which makes the leaky model 0.0133 AUC worse than the honest one in production. Excellent offline, worse in production, which is the whole shape of the problem.

A fixture that would not hold still. Described above and in ADR-002. The control scenario failing a drift assertion was the only reason anybody looked.


Layout

src/skewsentinel/     spec, offline path, online path, referee, compare, diagnose,
                      attribute, drift, model, report, charts, cli
skews/                the 16 injected scenarios and the world they run in
specs/fraud.yaml      declared semantics, units, tolerances, operating point
experiments/          5 experiments, each writing docs/experiments/*.json
tests/                219 tests across 9 files
tools/                metrics, number checking, demo, screenshots, video
docs/adr/             5 decision records

MIT licensed.

About

Arbitrates training and serving skew by implementing the declared feature semantics a third time, so a parity check that can only say "the two paths differ" becomes one that names the cause and blames a side. Names 16 of 16 scenarios against 13 without the referee, and measures severity in decisions rather than mismatches.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages