MLOps & LLMOps / The Model Lifecycle
Making runs reproducible and models findable.
Reviewed by Yuvaraj
Every trained model is the output of a specific recipe: this code, this data, these hyperparameters, this environment. Experiment tracking records that recipe and its results for every run, so you can compare candidates honestly and reproduce any of them later. A model registry then takes the winning run, freezes it as an immutable versioned model, and moves it through review stages until it is the artifact actually serving production traffic. Together they answer the two questions every ML team eventually has to answer under pressure: which model is in production right now, and exactly how did it get there?
A model and its reported metrics are not conjured from nothing, they are a deterministic function of a handful of inputs:
Ask about this lesson, or about anything in AI. Answers cite the lessons they draw on.
Finished this lesson?
Mark it complete to earn XP, keep your streak, and schedule a review.
If you cannot recover every argument on the right, you cannot recover the result on the left. "We got 0.91 AUC last quarter" is worthless if the notebook has since changed, the training table was overwritten in place, or nobody recorded the random seed. Reproducibility is not a nice-to-have; it is the precondition for comparing two runs at all, because a fair comparison requires that the only thing that changed between them is the thing you meant to change.
The practical discipline, then, is to pin every input: commit the code and record its git SHA, snapshot or hash the dataset, log the params and seed, and capture the environment (dependency lockfile or a container image digest). Anything left unpinned is a silent variable that will eventually make two "identical" runs disagree.
A run is a single execution of your training code. Runs are grouped into an experiment (e.g. all attempts at churn-classifier). For each run, a tracking tool records four kinds of information:
learning_rate, n_estimators, max_depth, seed. These are write-once; a param does not change during the run.val_auc, log_loss. Metrics can be logged repeatedly across steps or epochs, producing a time series you can plot to spot divergence or overfitting.Under the hood these split into a metadata store (small, structured, queryable, params, metrics, tags) and an artifact store (large blobs on object storage such as S3 or GCS). That separation is why you can scan thousands of runs in a table but still fetch the exact model.pkl for any one of them.
import mlflow
from sklearn.ensemble import GradientBoostingClassifier
mlflow.set_experiment("churn-classifier")
with mlflow.start_run(run_name="gbm-lr0.05-d6") as run:
params = {"learning_rate": 0.05, "n_estimators": 400,
"max_depth": 6, "random_state": 42}
mlflow.log_params(params)
mlflow.set_tag("dataset_version", "churn_v3") # a hash or DVC/Delta version
mlflow.set_tag("git_sha", "9f2c1a7") # often captured automatically
model = GradientBoostingClassifier(**params).fit(X_train, y_train)
mlflow.log_metric("val_auc", 0.912)
mlflow.log_metric("val_pr_auc", 0.68)
mlflow.log_metric("val_log_loss", 0.243)
mlflow.sklearn.log_model(model, artifact_path="model")
run_id = run.info.run_id # the durable handle to this exact recipe + result
Tools like MLflow and Weights & Biases implement this pattern; they are illustrative here, not endorsements. The concepts, runs, params, metrics, artifacts, lineage, are tool-agnostic.
Tracking preserves history; a model registry manages what ships. They are complementary, not interchangeable:
When a run wins, you register its model. This creates a new immutable version under a named model, and, critically, records lineage back to the source run:
result = mlflow.register_model(
model_uri=f"runs:/{run_id}/model", # points into the winning run's artifacts
name="churn-classifier",
)
# -> creates "churn-classifier" version 5, linked to run 9f2c1a7's recipe
Each version then moves through stages, conventionally None → Staging → Production → Archived. A stage is a label on a version, not the deployment itself: promoting to Production marks intent, and a serving system reads that label to decide what to load. The transition is where the promotion/approval workflow lives, automated validation in staging, then a human sign-off before anything reaches users.
from mlflow import MlflowClient
client = MlflowClient()
# 1) send the new version to staging for validation
client.transition_model_version_stage("churn-classifier", version=5, stage="Staging")
# 2) after approval, promote and auto-archive whatever was live
client.transition_model_version_stage(
"churn-classifier", version=5, stage="Production",
archive_existing_versions=True, # version 4 -> Archived, no gap, no ambiguity
)
Stages are a convention, not a law
Fixed stage names are one common design, but they are coarse, "Staging" means
different things to different teams. Newer MLflow versions deprecate
hardcoded stages (since 2.9) in favor of named aliases (e.g.
client.set_registered_model_alias("churn-classifier", "champion", 5)) plus
arbitrary tags. Aliases let serving code load
models:/churn-classifier@champion and let you flip the pointer atomically.
The underlying idea is unchanged: a stable name resolves to one specific,
immutable version.
The payoff of registering with lineage is a chain you can walk backwards during an incident. Serving loads Production → that resolves to version 5 → which links to run 9f2c1a7 → whose tags and params reveal the exact code SHA, dataset version, hyperparameters, seed, and environment. Because every link was pinned, you can rebuild the model, diff it against the previous version, or roll back to it deterministically. Without that chain, "which model is in prod and how did we get it" becomes an archaeology project instead of a database query.
The team is improving the churn classifier. A prior champion, run A, is already live as churn-classifier version 4. A new candidate, run B, is trained with a lower learning rate and deeper trees.
| Field | Run A (incumbent) | Run B (candidate) |
|---|---|---|
| Run ID | a41b8e0 | 9f2c1a7 |
| Git SHA | c07d3f2 | 9f2c1a7 |
| Dataset version | churn_v3 | churn_v3 |
learning_rate | 0.10 | 0.05 |
n_estimators | 200 | 400 |
max_depth | 4 | 6 |
| val_auc | 0.897 | 0.912 |
| val_log_loss | 0.271 | 0.243 |
| Registry | version 4 → Production | (not yet registered) |
Both runs used the same pinned dataset and differ only in hyperparameters and code, so the comparison is fair: run B genuinely improves AUC (0.912 vs 0.897) and log loss. The team registers run B's artifact as version 5, moves it to Staging, runs the validation suite and a shadow test against live traffic, and, on a reviewer's approval, promotes version 5 to Production while auto-archiving version 4. Serving now resolves Production to version 5, and its lineage still points at run 9f2c1a7 for any future audit or rollback.
| What to log | Example | Why it matters |
|---|---|---|
| Hyperparameters | learning_rate=0.05 | The knobs you tuned; needed to reproduce and to explain a result. |
| Metrics (per step) | val_auc=0.912 | The outcome; the time series reveals overfitting and divergence. |
| Code version | git SHA 9f2c1a7 | Ties the result to exact logic; a clean tree makes the SHA trustworthy. |
| Dataset version | churn_v3 / hash | Same code on different data is a different experiment. |
| Environment | lockfile / image digest | Library drift changes numerics even with identical code + data. |
| Random seed | 42 | Removes run-to-run noise so comparisons are apples-to-apples. |
| Artifacts | model, ROC curve | The deployable output plus evidence for review. |
| Lineage link | version 5 → run 9f2c1a7 | Makes "how did this reach prod?" a lookup, not an investigation. |
Common mistakes
model_latest.pkl or mutating a run destroys history; versions must be immutable so rollback and audit are possible.