MLOpsThe MLOps lifecycle · MLflow 3.17 · DVC 3.67 · FastAPI · Docker · GitHub Actions · Evidently · Python 3.11+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
50 small wins to finish your pathNext lesson →

Why a live model decays

Model decay is the loss of accuracy a deployed model suffers when the records it scores stop looking like the records it was fitted on. The file on disk never changes. The world it answers questions about does, and the score falls without a single line of code being touched.

Last updated: 19 Oct, 2026 · MLOps

Secrets and what never goes in git finished the automation: a push builds the image and ships it. From here the service is running, and the work changes shape. Nobody is editing code any more; the job is watching a model that was right in July and is quietly less right in November.

A model in production does not stay right, and why · from the Evidently AI Tutorial-Open Source ML Models Monitoring and Observability · 3:36 to 4:42

Why a frozen model goes wrong

  • The inputs move. A new intake of students, a new region, a new price band: the columns are the same, the distribution behind them is not. This is data drift, and the drift lesson measures it.
  • The relationship moves. The same inputs start mapping to different outcomes, because a policy changed or a competitor appeared. This is concept drift, and no amount of input checking spots it without labels.
  • A value shows up that was never in training. A category the encoder never saw becomes a row of zeros, and the model falls back on whatever else it has.
  • An upstream feed changes quietly. A column arrives in a different unit or starts coming through empty, and the pipeline keeps running because nothing raised an error.

The student-performance data gives a clean version of the third and the first together. Splitting the dataset by test_preparation_course gives two populations: 642 students who did no preparation course, and 358 who completed one. Fit on the first group, serve the second, and nothing in the service is wrong; the records are.

Splitting the students into a before and an after

The dataset loads straight from the repo, so there is nothing to download by hand. The two lists name the text columns and the score columns, which is all the preprocessor needs to know.

python
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# the course's dataset, read over the network so no file has to be fetched first
URL = "https://raw.githubusercontent.com/krishnaik06/mlproject/main/notebook/data/stud.csv"
df = pd.read_csv(URL)

# the five text columns and the two score columns, named once and reused
CAT = ["gender", "race_ethnicity", "parental_level_of_education", "lunch", "test_preparation_course"]
NUM = ["reading_score", "writing_score"]

Fitting the forest on one group only

fit_forest wraps the one-hot encoder, the scaler and the forest in a single sklearn Pipeline, with the settings the training pipeline uses: 100 trees, a depth of 12, seed 42. One object holds the preprocessing and the model, so there is no chance of scoring a frame the encoder never saw.

python
def fit_forest(frame):
    """Fit the course's preprocessor and forest on one slice of the students."""
    pre = ColumnTransformer([
        ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CAT),
        ("num", StandardScaler(), NUM),
    ])
    forest = RandomForestRegressor(n_estimators=100, max_depth=12, random_state=42)
    return Pipeline([("pre", pre), ("rf", forest)]).fit(
        frame.drop(columns=["math_score"]), frame.math_score)

# the group the model learns from, and the group that turns up later
ref = df[df.test_preparation_course == "none"]
cur = df[df.test_preparation_course == "completed"]

Scoring the same model on both groups

The model is fitted once, on 80% of the reference group. Then the same fitted object scores two things: the fifth of the reference group it was held back from, and the whole of the new group. One of those is the number a notebook reports. The other is the number the service lives with.

Example
from sklearn.metrics import mean_absolute_error, r2_score

# fitted once, on four fifths of the reference students, and never refitted after
train, held_out = train_test_split(ref, test_size=0.2, random_state=42)
model = fit_forest(train)

for name, group in [("held-out reference students", held_out), ("the new group", cur)]:
    truth = group.math_score
    pred = model.predict(group.drop(columns=["math_score"]))
    print(f"{name:28} r2 {r2_score(truth, pred):.4f}  MAE {mean_absolute_error(truth, pred):.4f}")

Reading the two scores

  • The held-out reference students are the notebook number. They were never trained on, but they come from the same population, and that is the number that gets written in the model card.
  • The new group is the production number. Same file, same code, a different intake of students, and the error is most of a mark worse per prediction.
  • The drop is small and that is the problem. Nothing crashed, no alert fired, and the service carried on answering. Decay rarely arrives as a failure; it arrives as a number nobody is looking at.
  • One cause is visible in the data. Every record in the new group carries test_preparation_course set to completed, a value the encoder never saw during training, so it is encoded as all zeros.
A frozen model meeting two different populations. On the left, the model on disk, fitted once on the students who did no preparation course and unchanged since, labelled model.joblib. Two arrows run from it. The upper arrow, in green, goes to held-out students who also did no preparation course, and that group scored r2 0.7817 with a mean absolute error of 5.14. The lower arrow, in red, goes to students who completed the preparation course, and that group scored r2 0.7477 with a mean absolute error of 5.80. A note underneath says nothing in the service changed: the same file scored 0.0340 of r2 worse and was 0.65 marks further out, because the records coming in are not the records it learned from.

A notebook model against a served model

In the notebookIn the service
The dataOne file, split onceA stream that keeps arriving
The test setDrawn from the same file as trainingWhatever turns up this month
The scoreComputed once and written downOnly known when the true value comes back
A bad resultFix it and rerun the cellAlready shown to a user
Who noticesYou, immediatelyNobody, unless something is watching

Catching decay before the labels arrive

  • True values are late, and sometimes never come. A predicted exam mark is checkable in weeks; a predicted churn is checkable in months. Waiting for labels to notice decay means months of wrong answers.
  • The inputs are there immediately. Every request carries its own features, so the shift in the inputs can be measured the same day, long before any mark is recorded.
  • That is why drift detection exists. It is the early signal: not proof the model is wrong, but evidence that it is being asked something new.
Watch out. A model that scores well on a held-out split of the training file has proved one thing only: it generalises inside that population. It has not proved anything about next quarter. The honest version of a model card carries the date of the data it was fitted on, next to the score.
Try it yourself
  • Swap the two groups: fit on the students who completed the course and score the ones who did not. Note which direction costs more.
  • Drop test_preparation_course from CAT, refit, and score both groups again. The gap narrows, which tells you where part of it came from.
  • Change test_size to 0.5 in the split and watch the held-out number move while the new-group number barely does.

This is what real progress feels like.