Lesson 09 / 13
Retraining Policy
Counting the triggers that decide when a model is retrained and what that decision really gains: in production with a data drift all three retraining windows give 0.9800 in the period 2026-12, and this is exactly the same as the number of the old model that was never retrained. When the calendar, indicator and performance triggers are run in two degradation scenarios, the accuracy gained is 0.0000 in all six of six combinations, and in return 3,078,000 rows are scored. That what determines the gain is the window and not the trigger is shown by the retraining triggered at the same moment under a concept drift gaining 0.0000 with window 6 and 0.1100 with window 1.
Published Updated
In this lesson
- Sweeps the retraining window and shows that all three windows give the same production accuracy as the old model.
- Runs the calendar, indicator and performance triggers in two degradation scenarios and counts how many models each one trains.
- Writes the cost of retraining as rows scored and puts it next to the accuracy gained.
- Justifies that the gain comes not from the trigger but from which rule's periods the training window contains.
Contents
The previous lesson measured the cost of comparing two models in production: the minimum difference a single-period experiment can distinguish on this set lies between 0.0050 and 0.0275, and every comparison below it is inconclusive. That comparing is expensive sharpens the question before it: why is the new model being trained.
In production the answer to this question is a retraining trigger: the calendar has come, an indicator has moved outside its band, or accuracy has really dropped. This lesson runs three kinds of triggers in two separate degradation scenarios and writes two numbers next to each: how many models were trained and how much accuracy was gained. The second number is the clearest example of the second claim of this course.
- RG21. The source, the pipeline and the model are fictional; no learning library, orchestrator, deployment tool or monitoring product is called. Seed 20260218.
- RG22. The label is tied to the rule
subscriber_mean + 6 x commercial > 30and this rule is the concept. The decision threshold is a separate setting, the training criterion is balanced accuracy, that is, the mean of recall and specificity. In the monitored periods the base class ratio is 0.5342, there are 200 rows per period and the step of accuracy is 0.0050. - RG23. There are two scenarios. In the data drift the input drifts and the rule stays the same. In the concept drift the input stays the same and the threshold of the rule falls from 30 to 27 starting from
2026-09. - RG24. The label is delayed: the label of a period is known two periods later. Retraining can use only the labeled periods.
- RG25. Training cost is counted as rows scored: every retraining scores the rows in the window for 135 candidates. Raw time is not written.
- RG26. The gain is measured against the
2026-12accuracy of the old model that was never retrained in that scenario.
Window Sweep: The Best Case
The first block sets up the setup and retrains with three separate windows in production with a data drift. The label delay is ignored in this block, that is, the best case that can be reached is measured.
# FICTIONAL. The meter readings of the measurement network, the consumption stage of the # pipeline, the feature path and the three-weight threshold model are generated inside # the lesson; no learning library, deployment tool, experiment platform or monitoring # product is called. # Seed 20260218. SEED, M32 = 20260218, 0xFFFFFFFF def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value REGION = ["east", "west", "north", "south", "central"] TYPE = ["residential", "commercial"] PERIODS = [f"2026-{a:02d}" for a in range(1, 13)] SUBSCRIBERS = {} for i in range(200): r = rng(SEED + 37 * i) SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)], "start": 10000 + int(r() * 60000)} def rows(): """Source readings and consumption from the period difference: the first three stages of the pipeline.""" records = [] for i, (sub, a) in enumerate(SUBSCRIBERS.items()): e = a["start"] for p in PERIODS: r = rng(SEED + 101 * i + 7 * PERIODS.index(p)) e += 4 + int(r() * 46) records.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"], "index": e}) t = {(k["subscriber"], k["period"]): k["index"] for k in records} return [dict(k, m3=k["index"] - t[(k["subscriber"], PERIODS[PERIODS.index(k["period"]) - 1])]) for k in records if PERIODS.index(k["period"])] def features(records): """Independent of the weights and computed ONCE; it sees the full history.""" sub, reg = {}, {} for k in sorted(records, key=lambda k: PERIODS.index(k["period"])): sub.setdefault(k["subscriber"], []).append((PERIODS.index(k["period"]), k["m3"])) reg.setdefault(k["region"], []).append((PERIODS.index(k["period"]), k["m3"])) def mean(series, i): s = [v for d, v in series if d < i] return round(sum(s) / len(s), 4) if s else 0.0 return {(k["subscriber"], k["period"]): { "subscriber_mean": mean(sub[k["subscriber"]], PERIODS.index(k["period"])), "region_mean": mean(reg[k["region"]], PERIODS.index(k["period"])), "commercial": 1 if k["type"] == "commercial" else 0} for k in records} RULE_THRESHOLD, RULE_COMMERCIAL = 30, 6 # the RULE that generates the label , in other words the concept MONITORED = PERIODS[6:] # six periods monitored in production GRID = [(a, b, c) for a in (0.6, 0.8, 1.0) for b in (0.0, 0.2, 0.4) for c in (0, 4, 8)] DECISION_GRID = (18, 22, 26, 30, 34) # the decision threshold is A SEPARATE setting def label(o, threshold=RULE_THRESHOLD): return 1 if o["subscriber_mean"] + RULE_COMMERCIAL * o["commercial"] > threshold else 0 def score(o, w): return w[0] * o["subscriber_mean"] + w[1] * o["region_mean"] + w[2] * o["commercial"] def predict(o, w, decision): return 1 if score(o, w) > decision else 0 def train(records, feats, periods, labels): """Weights and decision threshold are searched TOGETHER (27 x 5 = 135 candidates); the criterion is the mean of recall and specificity, that is BALANCED ACCURACY.""" subset = [k for k in records if k["period"] in periods] best = (-1.0, None, None, 0.0, 0.0) for w in GRID: for dc in DECISION_GRID: tp = tn = fp = fn = 0 for k in subset: o = feats[(k["subscriber"], k["period"])] p, g = predict(o, w, dc), labels[(k["subscriber"], k["period"])] if p and g: tp += 1 elif p: fp += 1 elif g: fn += 1 else: tn += 1 recall = tp / (tp + fn) if tp + fn else 0.0 spec = tn / (tn + fp) if tn + fp else 0.0 if (recall + spec) / 2 > best[0]: best = ((recall + spec) / 2, w, dc, recall, spec) return {"weights": best[1], "decision": best[2], "balanced": round(best[0], 4), "recall": round(best[3], 4), "specificity": round(best[4], 4), "rows": len(subset)} def drift(records, start="2026-09", slope=0.06): """FICTIONAL data drift: commercial consumption grows by `slope` every period.""" b = PERIODS.index(start) return [dict(k, m3=round(k["m3"] * (1.0 + slope * (PERIODS.index(k["period"]) - b + 1)), 4)) if PERIODS.index(k["period"]) >= b and k["type"] == "commercial" else dict(k) for k in records] def concept_drift(feats, records, start="2026-09", new_threshold=27): """FICTIONAL concept drift: SAME INPUT , the rule changes (threshold 30 to 27).""" b = PERIODS.index(start) return {(k["subscriber"], k["period"]): label( feats[(k["subscriber"], k["period"])], new_threshold if PERIODS.index(k["period"]) >= b else RULE_THRESHOLD) for k in records} def accuracy(records, feats, m, period, labels): subset = [k for k in records if k["period"] == period] return round(sum(1 for k in subset if predict(feats[(k["subscriber"], k["period"])], m["weights"], m["decision"]) == labels[(k["subscriber"], k["period"])]) / len(subset), 4) def prediction_share(records, feats, m, period): subset = [k for k in records if k["period"] == period] return round(sum(predict(feats[(k["subscriber"], k["period"])], m["weights"], m["decision"]) for k in subset) / len(subset), 4) ALL = rows() FEATS = features(ALL) PRODUCTION = drift(ALL) # the input has drifted in production FEATS_D = features(PRODUCTION) LABELS_D = {c: label(o) for c, o in FEATS_D.items()} # data drift: SAME RULE LABELS_C = concept_drift(FEATS, ALL) # concept drift: the rule changed BASELINE = train(ALL, FEATS, PERIODS[1:9], {c: label(o) for c, o in FEATS.items()}) monitored_rows = [k for k in ALL if k["period"] in MONITORED] base_labels = [label(FEATS[(k["subscriber"], k["period"])]) for k in monitored_rows] print(f"baseline model {BASELINE['weights']}/{BASELINE['decision']} balanced accuracy {BASELINE['balanced']}" f" recall {BASELINE['recall']:.4f} specificity {BASELINE['specificity']:.4f}") print(f"monitoring {len(monitored_rows)} rows , base class ratio {round(1 - sum(base_labels) / len(base_labels), 4)}" f" , 200 rows per period , accuracy step 0.0050") # Retraining in production with a data drift: the BEST case that could be reached # if all labels were at hand , that is if there were no delay at all. print("\n window training periods rows balanced accuracy 2026-12 accuracy") for p in (3, 6, 11): span = [d for d in PERIODS if PERIODS.index("2026-11") - p < PERIODS.index(d) <= PERIODS.index("2026-11")] m = train(PRODUCTION, FEATS_D, span, LABELS_D) print(f"{p:>7} {span[0]}..{span[-1]} {m['rows']:>5} {m['balanced']:>17}" f" {accuracy(PRODUCTION, FEATS_D, m, '2026-12', LABELS_D):>16}") print(f" old model {BASELINE['balanced']}" f" {accuracy(PRODUCTION, FEATS_D, BASELINE, '2026-12', LABELS_D):>16}")
baseline model (0.6, 0.0, 4)/18 balanced accuracy 0.9864 recall 1.0000 specificity 0.9728
monitoring 1200 rows , base class ratio 0.5342 , 200 rows per period , accuracy step 0.0050
window training periods rows balanced accuracy 2026-12 accuracy
3 2026-09..2026-11 600 0.9824 0.98
6 2026-06..2026-11 1200 0.98 0.98
11 2026-01..2026-11 2000 0.9849 0.98
old model 0.9864 0.98
As the training window grows, the balanced accuracy on training moves: 0.9824, 0.9800, 0.9849. These three numbers give the impression that a choice can be made. The last column erases that impression. All three windows give 0.9800 on 2026-12, and this is exactly the same as the number of the old model that was never retrained. All three have found the same weights and the same decision threshold, because the rule the search is trying to solve has not changed. The input has drifted, but as long as the relation that produces the label stays in place, a correct model stays correct on a drifted input as well. The accuracy gained is 0.0000 and the training cost has been paid.
Trigger or Window
The second block runs the three triggers in the two scenarios. The label delay is now applied: a retraining triggered in a period sees labeled data only up to two periods earlier. The fired column is the number of periods in which the trigger fired, that is, how many models were trained.
# This block continues the ALL, FEATS, PRODUCTION, FEATS_D, BASELINE, LABELS_D, LABELS_C, # MONITORED, GRID, DECISION_GRID, train, accuracy and prediction_share definitions # of the first block. DELAY, WINDOW, CACHE = 2, 6, {} def trigger(name, records, feats, labels, now, window=WINDOW): """The label is delayed: in period `now` only `now - DELAY` is labeled.""" i = PERIODS.index(now) - DELAY span = [d for d in PERIODS if i - window < PERIODS.index(d) <= i] key = (name, span[0], span[-1], window) if key not in CACHE: CACHE[key] = train(records, feats, span, labels) m = CACHE[key] return m, m["rows"] * len(GRID) * len(DECISION_GRID) def deviating(series, base, band=0.05): return [d for d, v in series if abs(v - base) / base > band] print("scenario trigger fired rows scored 2026-12 gain") for name, recs, feats, lab in (("data drift", PRODUCTION, FEATS_D, LABELS_D), ("concept drift", ALL, FEATS, LABELS_C)): base_12 = accuracy(recs, feats, BASELINE, "2026-12", lab) base_08 = accuracy(recs, feats, BASELINE, "2026-08", lab) for t, fired_periods in ( ("calendar", list(MONITORED)), ("indicator", deviating([(d, prediction_share(recs, feats, BASELINE, d)) for d in MONITORED], prediction_share(recs, feats, BASELINE, "2026-08"))), ("performance", deviating([(d, accuracy(recs, feats, BASELINE, PERIODS[PERIODS.index(d) - DELAY], lab)) for d in MONITORED[DELAY:]], base_08))): cost, last_model = 0, None for d in fired_periods: m, b = trigger(name, recs, feats, lab, d) cost += b last_model = m if PERIODS.index(d) < PERIODS.index("2026-12") else last_model acc_12 = accuracy(recs, feats, last_model, "2026-12", lab) if last_model else base_12 print(f"{name:<15} {t:<11} {len(fired_periods):>9} {cost:>15} {acc_12:>7} {acc_12 - base_12:+.4f}") print("\nconcept drift , retraining triggered in 2026-11: window choice") for p in (1, 3, 6): m, _ = trigger("cw", ALL, FEATS, LABELS_C, "2026-11", window=p) acc_12 = accuracy(ALL, FEATS, m, "2026-12", LABELS_C) print(f" window {p} {str(m['weights']) + '/' + str(m['decision']):<18}" f" training rows {m['rows']:>4} 2026-12 accuracy {acc_12}" f" gain {acc_12 - accuracy(ALL, FEATS, BASELINE, '2026-12', LABELS_C):+.4f}")
scenario trigger fired rows scored 2026-12 gain data drift calendar 6 891000 0.98 +0.0000 data drift indicator 3 486000 0.98 +0.0000 data drift performance 0 0 0.98 +0.0000 concept drift calendar 6 891000 0.85 +0.0000 concept drift indicator 3 486000 0.85 +0.0000 concept drift performance 2 324000 0.85 +0.0000 concept drift , retraining triggered in 2026-11: window choice window 1 (0.6, 0.2, 4)/22 training rows 200 2026-12 accuracy 0.96 gain +0.1100 window 3 (0.8, 0.4, 4)/34 training rows 600 2026-12 accuracy 0.85 gain +0.0000 window 6 (0.6, 0.0, 4)/18 training rows 1200 2026-12 accuracy 0.85 gain +0.0000
In all six of six rows the gain is 0.0000. A total of 3,078,000 rows have been scored, retraining has run twenty times, and the number read on 2026-12 has come out every time as the number of the case without a trigger. Reading the rows one by one shows that there are three separate reasons for this.
The performance trigger never fires in the data drift and this is the correct decision: there is nothing to gain, and its cost is 0. The same trigger fires in the concept drift but two periods late; the accuracy that dropped in 2026-09 is known only in 2026-11. The indicator trigger fires in the same three periods in both scenarios. It does not tell the scenario with harmful degradation from the harmless one, because the prediction share it looks at drifts in the same way in both. The rule of the course is written on this row: an indicator’s firing is not evidence of degradation. The calendar trigger looks at nothing, trains the most models and pays the largest cost.
The last table gives the real reason. In the concept drift, the retraining triggered in 2026-11, at the same moment, with the same data and the same grid, gives 0.8500 with window 6 and 0.9600 with window 1; the gain varies between 0.0000 and 0.1100. Window 6 sees together the periods of which one belongs to the new rule and five to the old rule, and the search that maximizes balanced accuracy follows the majority and finds the old model again. Window 1 sees only the period of the new rule and lands on another model. What retraining gains is not the trigger but which rule’s periods the window contains. The gain of 0.1100 is far above the minimum detectable effect of the previous lesson; 0.0000 is zero anyway.
The measurable counterpart of the approval flow comes from here. When a retraining proposal comes up for approval, the sentence “the indicator fired” alone is not a justification; the three numbers that make the decision possible are its window, the number of predictions on which the candidate disagrees with the old model and its gain measured in the shadow. When the three are not written, the approval also approves a training whose result is 0.0000.
Summary
- In production with a data drift, three retraining windows give 0.9824, 0.9800 and 0.9849 in training, but on
2026-12all three are 0.9800 — exactly the same as the old model that was never retrained. The base class ratio is 0.5342, the step of accuracy is 0.0050. If the rule has not changed, retraining gains nothing and the training cost has been paid. - In all six of six combinations of three triggers and two scenarios the gain is 0.0000; in return 3,078,000 rows are scored. The calendar trigger is the most expensive (891,000 rows), and the performance trigger never fires in the data drift and its cost is 0.
- The indicator trigger fires in the same three periods in both scenarios; it does not tell harmful degradation from the harmless one. An indicator’s firing is not evidence of degradation.
- The performance trigger sees the concept drift but sees it two periods late; the accuracy that dropped in
2026-09triggers in2026-11. - What determines the gain is not the trigger but the window: the retraining triggered at the same moment gives +0.0000 with window 6 and +0.1100 with window 1. A window containing the periods of the old rule adapts the search to the majority and finds the old model again.
Next Step
Over three lessons the model was measured in the shadow, given to the canary, compared by experiment and retrained. At every step a new model, a new window and a new decision threshold came out. The only thing not asked is to whom a single prediction given to a subscriber on 2026-12 will be put months later: which model produced it, with which feature values, with which decision threshold. The next lesson counts this question as a search — how many rows are read to find the reason for a prediction with and without a decision record, how many predictions in the canary period cannot be explained by any reading, and how many fields the record itself costs to store.
To keep your progress and take notes, Log in
My notes
Log in to take notes.