---
title: 'Data and Model Versioning'
source: 'https://academia.sh/en/courses/mlops-fundamentals/data-and-model-versioning'
course: 'MLOps Fundamentals'
language: en
updated: '2026-10-21T05:03:01+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Separates the name of a data delivery with a signature produced from its content.'
    - 'Compares the number of runs left ambiguous by unversioned, date-named, and content-addressed schemes.'
    - "Shows that a model's identity is not only its weights and computes the cost of versioning in stored rows."
---

# Data and Model Versioning

Measuring how well two different contents given the same name are told apart: when four deliveries come under the same `measurement-2026` name with 2200, 2180, 2160, and again 2200 rows, in an unversioned setup a single name leaves all four runs ambiguous and three different test numbers, 0.8067, 0.8064, and 0.8078, stand behind the same name, while naming by content signature produces three names and brings ambiguous runs to zero; on the model side, because the weights of the four runs are exactly the same, a model identity that looks only at the weights collects the four runs into a single version and three versions separate only when the data signature is added; as the cost, the date-named scheme stores 8740 rows while the content-addressed scheme stores 6540 rows and 8740 rows are read for the signature at every delivery; the output signature does not see a change the pipeline swallowed, and when sixty records arrive twice the source signature changes while the output signature and the accuracy stay exactly the same.

The previous lesson's ledger wrote down how many rows each run read. All eighteen runs read their data
from the same place, and the name of that place never changed. When the ledger says "2200 rows", we
assumed that two runs read the same 2200 rows, but that assumption was never tested anywhere.

This lesson tests that assumption. Data lineage, that is, the record of the link from a serving value
back to the source, was measured in the Introduction to Data Engineering course and is not repeated
here. The question here is narrower: when the content under a name changes and the name does not, how
many runs become indistinguishable from one another.

- **RP19.** The fictional producer brings the data in **four deliveries**. The deliveries are 2200,
  2180, 2160, and again 2200 rows; all four are written under the same `measurement-2026` name. **The
  fourth delivery is exactly identical in content to the first.**
- **RP20.** A **data version** is the pairing of a name with a content. Three schemes are compared:
  **unversioned** (a single name, overwritten), **date-named** (one name per delivery), and
  **content-addressed** (the name carries the signature produced from the content).
- **RP21.** A **signature** is a short digest produced from the content and is written within the
  lesson; no external library is called. A signature is not a security tool but an **identity** tool.
- **RP22.** The baseline is the **unversioned setup**: a single name, overwritten content, no
  retroactive reproduction.
- **RP23.** The units measured are **the number of names**, **ambiguous runs**, and **stored rows**. An
  ambiguous run is a run that carries the same name yet gives a different result under that name.
- **RP24.** The feature code is **fixed** in this lesson; all four runs see the full history and round
  to four digits. The only thing that changes is **the content of the data**.
- **RP25.** The label distribution is `{0: 1752, 1: 448}`, the **base class ratio is 0.7964**, and the
  accuracy numbers in this lesson carry no performance claim. The resolution is **0.0017**.

## The Content Under the Same Name

The first block rebuilds, verbatim, the fictional source, pipeline and model of the previous two
lessons.

```python
# FICTIONAL. Meter readings, the pipeline and the model are defined within the lesson; no real
# learning library or model registry product is called.
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 generate_source():
    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, "unit": "m3"})
    return records


def s1_screen(records):
    REQUIRED = ("subscriber", "period", "region", "index", "unit")
    keep = [k for k in records if all(a in k for a in REQUIRED)]
    return keep, len(records) - len(keep)


def s2_dedupe(records):
    seen, keep = set(), []
    for k in records:
        key = (k["subscriber"], k["period"])
        if key in seen:
            continue
        seen.add(key)
        keep.append(k)
    return keep, len(records) - len(keep)


def s3_consumption(records):
    table = {(k["subscriber"], k["period"]): k for k in records}
    out, dropped = [], 0
    for k in records:
        i = PERIODS.index(k["period"])
        if i == 0:
            continue
        prev = table.get((k["subscriber"], PERIODS[i - 1]))
        if prev is None:
            dropped += 1
            continue
        try:
            f = int(k["index"]) - int(prev["index"])
        except (TypeError, ValueError):
            dropped += 1
            continue
        out.append(dict(k, m3=f))
    return out, dropped


TRAIN = PERIODS[1:9]
TEST = PERIODS[9:]
THRESHOLD = 40


def rows():
    v, _ = s1_screen(generate_source())
    v, _ = s2_dedupe(v)
    v, _ = s3_consumption(v)
    return v


def label(k):
    return 1 if k["m3"] > THRESHOLD else 0


def features(records, window=None, digits=4):
    """Independent of the weights , computed once."""
    ordered = sorted(records, key=lambda k: PERIODS.index(k["period"]))
    sub, reg = {}, {}
    for k in ordered:
        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):
        chosen = [v for d, v in series if (d < i and (window is None or d >= i - window))]
        return round(sum(chosen) / len(chosen), digits) if chosen else 0.0

    feats = {}
    for k in records:
        i = PERIODS.index(k["period"])
        feats[(k["subscriber"], k["period"])] = {
            "subscriber_mean": mean(sub[k["subscriber"]], i),
            "region_mean": mean(reg[k["region"]], i),
            "commercial": 1 if k["type"] == "commercial" else 0}
    return feats


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


def score(o, w):
    return w[0] * o["subscriber_mean"] + w[1] * o["region_mean"] + w[2] * o["commercial"]


def train(records, feats, grid=None, threshold=THRESHOLD, periods=None):
    grid = GRID if grid is None else grid
    periods = TRAIN if periods is None else periods
    subset = [k for k in records if k["period"] in periods]
    best, best_w = -1.0, None
    for w in grid:
        d = sum(1 for k in subset
                if (1 if score(feats[(k["subscriber"], k["period"])], w) > threshold else 0) == label(k))
        o = d / len(subset)
        if o > best:
            best, best_w = o, w
    return {"weights": best_w, "train_accuracy": round(best, 4),
            "candidates": len(grid), "train_rows": len(subset)}


def evaluate(records, model, feats, threshold=THRESHOLD, periods=None):
    periods = TEST if periods is None else periods
    subset = [k for k in records if k["period"] in periods]
    d = sum(1 for k in subset
            if (1 if score(feats[(k["subscriber"], k["period"])], model["weights"]) > threshold else 0)
            == label(k))
    return round(d / len(subset), 4)
```

The four deliveries run in the block below. In each delivery the same code reads the data under the same
name, trains the model, and writes the result under the name `consumption-threshold`. `signature`
produces a short digest from the content and is only reported for now; it enters no decision.

```python
S = rows()


def signature(text):
    """A short digest produced from the content. Not security , an identity tool."""
    h = 2166136261
    for c in text:
        h = ((h ^ ord(c)) * 16777619) & 0xFFFFFFFF
    return f"{h:08x}"


def data_signature(records):
    return signature("|".join(f"{k['subscriber']}:{k['period']}:{k['m3']}" for k in records))


DELIVERIES = [2200, 2180, 2160, 2200]
RUNS = []
for no, n in enumerate(DELIVERIES, start=1):
    rec = S[:n]
    feats = features(rec)
    m = train(rec, feats)
    RUNS.append({"run": no, "data_name": "measurement-2026", "rows": n,
                 "data_signature": data_signature(rec), "model_name": "consumption-threshold",
                 "weights": m["weights"], "test": evaluate(rec, m, feats)})
print(f"{'delivery':<10}{'data name':<18}{'rows':>7}{'data signature':>16}"
      f"{'model name':>24}{'weights':>17}{'test':>9}")
for k in RUNS:
    print(f"{k['run']:<10}{k['data_name']:<18}{k['rows']:>7}{k['data_signature']:>16}"
          f"{k['model_name']:>24}{str(k['weights']):>17}{k['test']:>9.4f}")
print()
print("different test numbers:", len(set(k["test"] for k in RUNS)),
      " different weights:", len(set(k["weights"] for k in RUNS)),
      " different data contents:", len(set(k["data_signature"] for k in RUNS)))
```

```
delivery  data name            rows  data signature              model name          weights     test
1         measurement-2026     2200        bd88a253   consumption-threshold    (0.6, 0.4, 0)   0.8067
2         measurement-2026     2180        f008c71c   consumption-threshold    (0.6, 0.4, 0)   0.8064
3         measurement-2026     2160        cebfcc57   consumption-threshold    (0.6, 0.4, 0)   0.8078
4         measurement-2026     2200        bd88a253   consumption-threshold    (0.6, 0.4, 0)   0.8067

different test numbers: 3  different weights: 1  different data contents: 3
```

In the table, two columns never change: the data name is `measurement-2026` in all four rows, and the
model name is `consumption-threshold` in all four rows. One more column never changes: the weights are
`(0.6, 0.4, 0)` in all four runs, that is, the model file that is produced is **exactly the same**. Two
columns change: the content signature takes three different values, and the test accuracy gives three
different numbers.

## How Many Contents a Name Has

What is to be counted is now clear. If a naming scheme leaves runs that carry the same name yet give
different results, those runs are **ambiguous**: which content they came from cannot be read from the
name.

```python
def measure(identity):
    """How many names there are , how many runs stay ambiguous , how many rows are stored."""
    group = {}
    for k in RUNS:
        group.setdefault(identity(k), []).append(k)
    ambiguous = sum(len(v) for v in group.values() if len(set(x["test"] for x in v)) > 1)
    return len(group), ambiguous, sum(v[-1]["rows"] for v in group.values())


print(f"{'scheme':<18}{'data names':>12}{'ambiguous runs':>16}{'stored rows':>14}")
for name, ident in (("unversioned", lambda k: k["data_name"]),
                    ("date named", lambda k: f"{k['data_name']}@t{k['run']}"),
                    ("content addressed", lambda k: f"{k['data_name']}@{k['data_signature']}")):
    a, b, s = measure(ident)
    print(f"{name:<18}{a:>12}{b:>16}{s:>14}")
print()
print(f"{'model identity':<26}{'versions':>10}{'ambiguous runs':>16}")
for name, ident in (("weights only", lambda k: signature(str(k["weights"]))),
                    ("data signature + weights", lambda k: signature(f"{k['data_signature']}|{k['weights']}"))):
    a, b, _ = measure(ident)
    print(f"{name:<26}{a:>10}{b:>16}")
print()
print("rows read for the signature:", sum(DELIVERIES))
```

```
scheme              data names  ambiguous runs   stored rows
unversioned                  1               4          2200
date named                   4               0          8740
content addressed            3               0          6540

model identity              versions  ambiguous runs
weights only                       1               4
data signature + weights           3               0

rows read for the signature: 8740
```

In the unversioned setup there is a single name, and **all four runs are ambiguous**. This row is the
data-side form of the course's rule: when the number 0.8078 goes into a report, the content that
produced it no longer exists, because the fourth delivery overwrote the third. The stored rows column
confirms it: the unversioned scheme takes up the least room, **2200 rows**, and in return loses the
whole history.

The date-named scheme brings the ambiguity to zero, but it has a flaw: it produces four names although
there are three contents. The first and the fourth deliveries are **exactly the same content**, and
since the date scheme cannot see this, it stores the same 2200 rows twice. The content-addressed scheme
covers the same ambiguity with **three names** and **6540 rows**; the fourth delivery takes up no new
room, because its signature is already in the ledger.

## What the Signature Signs

`data_signature` signs the pipeline's **output**, that is, the triple of subscriber, period, and
computed consumption. This is a choice and it has a cost: a producer-side change that the pipeline
swallows never shows up in the output signature. The block below measures this. Suppose the producer
sends sixty records twice in July; the source has changed, but the deduplication stage drops the
repeated rows.

```python
def source_signature(records):
    return signature("|".join(f"{k['subscriber']}:{k['period']}:{k['index']}" for k in records))


def feed_pipeline(source):
    v, _ = s1_screen(source)
    v, _ = s2_dedupe(v)
    v, _ = s3_consumption(v)
    return v


raw = generate_source()
# The producer sent 60 records twice: the source changed , the pipeline swallows it.
duplicated = raw + [dict(k) for k in raw if k["subscriber"] <= "A060" and k["period"] == "2026-07"]
print(f"{'delivery':<12}{'source rows':>13}{'source signature':>18}"
      f"{'output rows':>13}{'output signature':>18}{'test':>9}")
for name, source in (("intact", raw), ("duplicated", duplicated)):
    c = feed_pipeline(source)
    feats2 = features(c)
    m2 = train(c, feats2)
    print(f"{name:<12}{len(source):>13}{source_signature(source):>18}{len(c):>13}"
          f"{data_signature(c):>18}{evaluate(c, m2, feats2):>9.4f}")
print()
print(f"{'delivery':<10}{'rows':>7}{'test rows':>11}{'resolution':>12}")
for n in DELIVERIES[:3]:
    test_rows = [k for k in S[:n] if k["period"] in TEST]
    print(f"{n:<10}{n:>7}{len(test_rows):>11}{1 / len(test_rows):>12.4f}")
```

```
delivery      source rows  source signature  output rows  output signature     test
intact               2400          d501f04f         2200          bd88a253   0.8067
duplicated           2460          93576832         2200          bd88a253   0.8067

delivery     rows  test rows  resolution
2200         2200        600      0.0017
2180         2180        594      0.0017
2160         2160        588      0.0017
```

The source signature changed, the output signature stayed **exactly the same**, and so did the
accuracy. The version records of the two deliveries point to the same rows, although the content that
came from the producer is different. This is the **correct** behavior of the pipeline, and it is a
limit of versioning: the output signature does not see a change the pipeline swallowed. In the opposite
direction it is also lacking; a scheme that signs the source flags a version difference here although
the data going to the model has not changed at all. The two signatures answer two separate questions,
and a data version stays incomplete **unless it is written which one is kept**.

The lower table closes another detail. As the delivery gets smaller, the test set gets smaller too:
600, 594, and 588 rows. Across the three deliveries the resolution stays at 0.0017, but the numbers
being compared were not measured **on the same set**. For this reason the difference between 0.8067 and
0.8064 cannot be read as a performance difference.

## A Model's Identity Is Not Only Its Weights

The lower table carries the lesson's quietest finding. The model file is the same in all four runs, so
a scheme that names the model **by its own content** collects the four runs into **a single version**
and leaves all four ambiguous. This is the mirror image of the error on the data side: there a name
concealed three contents, here a content conceals three numbers.

The four model files look interchangeable, because they carry the same weights. They are not
interchangeable: each was measured on a different data set, and the numbers they report are 0.8067,
0.8064, and 0.8078. The differences of 0.0014 and 0.0011 in between are **below** the resolution of
0.0017, and the numbers were moreover not measured on the same set; the reported number is not enough
to tell these four files apart. When the data signature is added to the weights, the identity splits
into **three versions** and ambiguous runs fall to **0**. A model version is not the signature of the
model file but the joint signature of **the model file and the data version that produced it**.

## The Cost of Versioning and What It Does Not Say

The cost has three items. The first is **stored rows**: the content-addressed scheme holds 6540 rows,
**2.97 times** the unversioned scheme. The second is **rows read**: since the signature is computed from
the content, all the rows are read once more at every delivery, **8740 rows** over four deliveries. The
third is **the added step**: the write operation now has to compute a signature and append a ledger row.

What is gained in return is narrow and exact: versioning says **which content produced which number**.
What it does not say is which content is **correct**. Which of the three deliveries came in incomplete,
which was whole, and whether the number 0.8078 can be trusted are things none of the three signatures
tells; what tells them is the checks measured in the Introduction to Data Engineering course.
Versioning is not a quality tool but an **identity tool**, and its contribution is measured only by the
number of ambiguous runs.

## Summary

- Four deliveries brought 2200, 2180, 2160, and again 2200 rows under the same `measurement-2026`
  name; the name never changed, the content received **three different signatures**, and the test
  accuracy came out **0.8067**, **0.8064**, and **0.8078**.
- In the unversioned scheme there is a single name and **all four runs are ambiguous**; in the
  content-addressed scheme there are **three names** and ambiguous runs are **0**.
- The date-named scheme closes the ambiguity in the same way but produces **four names** and stores
  **8740 rows**; the content-addressed scheme does the same job with **three names** and **6540 rows**,
  that is, **2.97 times** the unversioned scheme, because it sees that the fourth delivery is the same
  as the first.
- The output signature does not see a change the pipeline swallowed: when sixty records arrive twice,
  the source signature changes and **the output signature and the accuracy stay exactly the same**. A
  data version stays incomplete unless it is written which signature is kept.
- The model file is exactly the same in all four runs. A model identity that looks only at the weights
  collects the four runs into **a single version**; when the data signature is added, **three versions**
  separate and ambiguous runs become **0**.
- Versioning says which content produced which number, but it does not say which is **correct**; that
  question belongs to the checks.

## Next Step

In three lessons the run itself was recorded and versioned: which items it was run with was written
down, which content it read was tied to a signature, and the three contents behind the same name were
told apart. All of this work was done **on the training side**. It was never asked whether the feature
that goes into the model could be computed by one piece of code during training and **by another piece
of code** when called in production. The next lesson puts those two paths side by side and measures a
single question: when the weights are exactly the same, why does the accuracy drop.
