Lesson 03 / 10
Data Engineering and Data Science Distinction
Printing the same six corruption classes side by side on the measure of two roles: the data engineer sees the row trace of five stages, while the model builder sees the mean absolute deviation (baseline 10.832 m3) of a forecast rule that takes the per-region mean served by the pipeline as its feature. The two measures rank the same six classes differently: schema_changed is the noisiest class in the engineer's trace (2200 rows fall to 1800) yet moves the model's deviation by only 0.003; late_arrival removes only 40 rows from the source yet moves the deviation by 0.105, shifting it the most; missing_partition lowers the deviation by 0.003, that is, the corruption improves the measure. unit_changed and type_drift leave no trace in either. The invisibility of the unit change comes from a telescoping identity: the sum of the eleven cells of a region equals the difference of the end indexes, and the corruption does not touch the ends; when the same table is read on the period axis, the July mean comes out as 40592074.02 instead of 26.7.
Published Updated
In this lesson
- Defines the boundary between data engineering and data science as the stage of the pipeline from which the measure is read.
- Measures the same six corruption classes separately with the row trace and the model performance measures and shows that the two measures rank them differently.
- Shows with numbers that a corruption can improve the model measure and justifies why a measure not getting worse is not proof of health.
- Explains with the telescoping sum identity why the unit change is invisible in both measures and compares that it becomes visible when read on the period axis.
Contents
The previous two lessons read the pipeline from a single eye. In production there is no single eye around this pipeline. There is a role that writes the stages, looks at the trace table and is alerted when a row drops; there is another role that takes the table at the end of the pipeline as a feature table, builds a rule or a model on it and looks at the performance number. The two stand at different places of the same pipeline.
The role distinction itself is not the subject of this curriculum; the boundaries of responsibility were drawn separately in the Introduction to Data Analytics, Introduction to Machine Learning and Introduction to AI Engineering courses. The only thing this lesson adds is seating the distinction on the stages of the pipeline: from which stage the number of which role is read, and who measures the stage that remains between the two numbers. When this question is asked, an unpleasant result appears: the same corruption reflects differently on the two measures, the two rank the six classes differently, and one class leaves no trace in either of them.
- DL16. The pipeline, the source, the six corruption classes and the seed are the same as in the previous two lessons.
- DL17. The procedure for role definitions is not repeated; it was measured in the three introductory courses above. Here the distinction is written only as the stage from which the measure is read.
- DL18. The measure of the data engineer is the row trace: the remaining counts of the five stages. This is the same as the table in the first lesson.
- DL19. The measure of the model builder is the performance: the mean absolute
deviation, in
m3, of a forecast rule that takes the per-region mean served by the pipeline as its feature. The rule divides the served value of a region by the subscriber count of that region and gives this as the forecast to every subscriber in the region. - DL20. The test set is the periods
2026-11and2026-12(400 rows). These two periods are not corrupted in any of the six classes; so every difference in the deviation comes from the model’s feature side, not from the test side. - DL21. The model builder takes the feature table from the pipeline and does not reach the source. This is the boundary in production: the served layer is the contract between the two roles.
- DL22. The procedure for feature engineering and model evaluation is not repeated; it was measured in the Data Preparation and Feature Engineering and Model Evaluation, Interpretability and Ethics courses. The model is deliberately at its simplest here. The aim is not for the model to be good, but what its measure is sensitive to.
- DL23. No check; the baseline is the uninstrumented pipeline, the corruption caught is 0, the lag is undefined. The pipeline is deterministic, a second run gives the same table. Time is not written.
Two Measures, the Same Pipeline
The block below builds the pipeline and the six corruption classes as they are, adds the model builder’s measure on top, and prints two numbers side by side for each class: is the row trace the same as the intact pipeline, is the mean absolute deviation the same as the intact pipeline.
# FICTIONAL pipeline, FICTIONAL corruption classes and a FICTIONAL forecast rule. 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 values stay Turkish: the summary table below is printed in sorted order # dogu/bati/kuzey/guney/merkez = east/west/north/south/central REGION = ["dogu", "bati", "kuzey", "guney", "merkez"] 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 CORRUPTIONS = ["late_arrival", "duplicated", "schema_changed", "unit_changed", "missing_partition", "type_drift"] def corrupt(records, cls, period="2026-07"): """Corrupts a single period , the remaining periods stay intact.""" out, counter = [], 0 for k in records: if k["period"] != period: out.append(k) continue if cls == "late_arrival": if k["subscriber"] <= "A040": counter += 1 continue out.append(k) elif cls == "duplicated": out.append(k) if k["subscriber"] <= "A060": out.append(dict(k)) counter += 1 elif cls == "schema_changed": y = dict(k) y["index_value"] = y.pop("index") counter += 1 out.append(y) elif cls == "unit_changed": y = dict(k) y["index"] = k["index"] * 1000 # liters instead of m3 y["unit"] = "m3" # LABEL DID NOT CHANGE counter += 1 out.append(y) elif cls == "missing_partition": if k["region"] == "kuzey": counter += 1 continue out.append(k) elif cls == "type_drift": y = dict(k) y["index"] = str(k["index"]) counter += 1 out.append(y) return out, counter 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 def s4_summary(records): totals = {} for k in records: key = (k["region"], k["period"]) totals[key] = totals.get(key, 0) + k["m3"] return totals, 0 def s5_serve(summary): region = {} for (b, d), v in summary.items(): region.setdefault(b, []).append(v) return {b: round(sum(v) / len(v), 2) for b, v in region.items()}, 0 STAGES = [("screen", s1_screen), ("dedupe", s2_dedupe), ("consumption", s3_consumption), ("summary", s4_summary), ("serve", s5_serve)] def pipeline(records): trace, data = [], records for name, f in STAGES: data, dropped = f(data) n = len(data) if hasattr(data, "__len__") else 0 trace.append({"stage": name, "remaining": n, "dropped": dropped}) return data, trace TEST_PERIODS = ["2026-11", "2026-12"] def model_metric(records): """Model builder , takes the per-region mean served by the pipeline as a feature.""" v1, _ = s1_screen(records) v2, _ = s2_dedupe(v1) t, _ = s3_consumption(v2) summ, _ = s4_summary(t) features, _ = s5_serve(summ) n = {} for k in t: n.setdefault(k["region"], set()).add(k["subscriber"]) forecast = {b: features[b] / len(n[b]) for b in features} test = [k for k in t if k["period"] in TEST_PERIODS] return round(sum(abs(k["m3"] - forecast[k["region"]]) for k in test) / len(test), 3), len(test) source = generate_source() base_trace = [a["remaining"] for a in pipeline(source)[1]] base_dev, base_n = model_metric(source) print("test rows", base_n, " baseline deviation", base_dev, " m3") print(f"{'label':<19}{'row trace':>26}{'rows':>7}{'deviation':>11}{'diff':>8}{'model':>7}") print(f"{'intact':<19}{str(base_trace):>26}{'-':>7}{base_dev:>11}{'-':>8}{'-':>7}") for cls in CORRUPTIONS: trace = [a["remaining"] for a in pipeline(corrupt(source, cls)[0])[1]] dev, _ = model_metric(corrupt(source, cls)[0]) print(f"{cls:<19}{str(trace):>26}{('yes' if trace == base_trace else 'no'):>7}" + f"{dev:>11}{round(dev - base_dev, 3):>8}" + f"{('yes' if dev == base_dev else 'no'):>7}")
test rows 400 baseline deviation 10.832 m3 label row trace rows deviation diff model intact [2400, 2400, 2200, 55, 5] - 10.832 - - late_arrival [2360, 2360, 2120, 55, 5] no 10.937 0.105 no duplicated [2460, 2400, 2200, 55, 5] no 10.832 0.0 yes schema_changed [2200, 2200, 1800, 45, 5] no 10.835 0.003 no unit_changed [2400, 2400, 2200, 55, 5] yes 10.832 0.0 yes missing_partition [2354, 2354, 2108, 53, 5] no 10.829 -0.003 no type_drift [2400, 2400, 2200, 55, 5] yes 10.832 0.0 yes
One Who Sees Rows and One Who Sees Performance
The two measures rank the same six classes differently, and this difference in ranking is the real number of the role distinction.
In the data engineer’s trace the noisiest class is schema_changed: the consumption output falls
from 2200 to 1800, the summary from 55 to 45. The change of a field name takes away a sixth
of the source, and this is a drop that cannot be overlooked on a dashboard table. The same class
moves the model builder’s measure by 0.003, that is, a relative difference of 0.0003 over
10.832. When the model builder looks at this number, they think nothing happened, and they are
right: even if a sixth of the training data is lost, the per-region mean comes out almost the same
from the remaining nine periods.
The opposite direction is more interesting. late_arrival removes only 40 rows from the
source, that is, 0.0167 of 2400 (the smallest step of the row ratio is 0.0004, so this ratio can be
read to three digits); this is a loss that falls below the band of most row count checks. In return
it moves the model builder’s deviation by 0.105, and among the six classes it is the one that
shifts it the most. The reason is not in the table but in the definition of the pipeline: the
missing rows are not randomly distributed, they are concentrated in the first forty subscribers and
pulled the region means down together. A loss that is small in number but one-directional is
more harmful than a loss that is large in number but balanced, and the row count does not write
this distinction.
The third row is the cleanest distinction between the two roles. duplicated is plainly visible in
the engineer’s trace: the screen output is 2460 instead of 2400. In the model builder’s
measure, however, the difference is exactly zero, because the deduplication stage drops the
sixty double records and the intact set reaches the feature table. This is a case where the
pipeline does its job right; still, the engineer needs to see it, because the next double record
from the same producer may not be caught at the same place.
The missing_partition row shows the most dangerous side of reading a measure. The deviation
falls from 10.832 to 10.829, that is, the corruption improves the model’s measure. The
improvement is not real; since two periods of region kuzey (north) are completely lost, the served mean of
that region is computed from the remaining nine cells and, by chance, falls slightly closer to the
test periods. The result is this: a measure not getting worse is not proof of health, because the
corruption can improve the measure too. The direction of the sign says nothing about the presence
of corruption.
What Is Visible in Neither
The last reading of the table is two rows: unit_changed and type_drift. In both, the row trace
is exactly the same as the intact pipeline, and the deviation is exactly the same. The
routine measure of both roles is completely blind to these two classes.
The reason is superficial for type_drift: the pipeline turns the text into a number, the
conversion succeeds, and neither the rows nor the values change. The only thing that sees it is a
type check that never looks at the value of the row, and such a check can run at the zeroth
step of the pipeline. The reason is structural for unit_changed, and the block below shows it.
def region_total(records): v1, _ = s1_screen(records) v2, _ = s2_dedupe(v1) t, _ = s3_consumption(v2) summ, _ = s4_summary(t) total = {} for (b, d), v in summ.items(): total[b] = total.get(b, 0) + v return total def period_mean(records): v1, _ = s1_screen(records) v2, _ = s2_dedupe(v1) t, _ = s3_consumption(v2) d = {} for k in t: d.setdefault(k["period"], []).append(k["m3"]) return {a: round(sum(v) / len(v), 2) for a, v in d.items()} first = {k["subscriber"]: k["index"] for k in source if k["period"] == PERIODS[0]} last = {k["subscriber"]: k["index"] for k in source if k["period"] == PERIODS[-1]} s_top, b_top = region_total(source), region_total(corrupt(source, "unit_changed")[0]) print("region ends diff intact summary corrupted summary") for b in sorted(s_top): ends = sum(last[a] - first[a] for a, v in SUBSCRIBERS.items() if v["region"] == b) print(f"{b:<9}{ends:>11}{s_top[b]:>16}{b_top[b]:>19}") s_d, b_d = period_mean(source), period_mean(corrupt(source, "unit_changed")[0]) print("period intact corrupted") for d in PERIODS[5:9]: print(f"{d:<10}{s_d[d]:>10}{b_d[d]:>15}")
region ends diff intact summary corrupted summary bati 9164 9164 9164 dogu 9695 9695 9695 guney 11721 11721 11721 kuzey 13338 13338 13338 merkez 14536 14536 14536 period intact corrupted 2026-06 24.08 24.08 2026-07 26.7 40592074.02 2026-08 25.55 -40592021.77 2026-09 28.96 28.96
The three columns of the first table give the same number for every region. The left column was
computed without going through the pipeline at all, directly from the end points of the source: the
last period index of the region’s subscribers minus the first period index. The middle and right
columns are the sum of the eleven summary cells of the pipeline, with the intact and the corrupted
source. The equality of the three is not a measurement result but an identity: the sum of
successive differences equals the difference of the ends. Because unit_changed does not touch the
ends, the total cannot change, and the served value, which divides the total by eleven, cannot
change either. This is a result known even without a run; the run has only verified it.
The second table reads the same feature table on the period axis, and there the corruption shouts: the July mean is 40592074.02 instead of 26.7, the August mean is negative 40592021.77 instead of 25.55. The data is not hiding it; what hides it is both roles summing over the period axis. The engineer sums the rows per stage, the model builder sums the values per region; the corruption lives exactly on the axis under these two sums, with two signs that cancel each other. The rule that follows from here is general and applicable: a measure that sums the axis on which the corruption lives cannot see that corruption.
The count of the role distinction can also be read from here. The engineer’s measure is read from the first, second and third stages; the model builder’s measure from the output of the fifth stage. Between them stands the fourth stage, and the routine measure of neither role reads those fifty-five cells. The place where the corruption sits is exactly there. The boundary is not a question of job definition; it is a question of which number is read from which stage of the pipeline, and as long as a stage remains unmeasured between the measures of the two roles, the boundary produces a gap.
Summary
- The boundary between data engineering and data science is not a job definition but the stage from which the measure is read: the engineer reads the row trace of the first to third stages, the model builder reads the output of the fifth stage.
- The two measures rank the same six classes differently:
schema_changedis the noisiest class in the trace (2200 rows fall to 1800) yet moves the deviation by only 0.003;late_arrivalremoves only 40 rows yet moves the deviation by 0.105, shifting it the most. - A loss that is small in number but one-directional is more harmful than a loss that is large in number but balanced, and the row count does not write this distinction.
missing_partitionlowers the deviation from 10.832 to 10.829: a corruption can improve the measure, so a measure not getting worse is not proof of health.unit_changedandtype_driftgive exactly the same result in both measures. The invisibility of the unit change comes from a telescoping identity: the sum of the eleven cells of a region equals the difference of the end indexes, and the corruption does not touch the ends.- When the same table is read on the period axis, the July mean comes out as 40592074.02 instead of 26.7; a measure that sums the axis on which the corruption lives cannot see that corruption.
Next Step
For three lessons the pipeline was treated as if it started where the data arrives. The source
came about with the generate_source call, and the corruption was applied to it from outside. Where
the data comes from was never asked. Yet the unit_changed in the last table of this lesson
happens through the change of a converter in the field, and which source class can carry such an
accident and which cannot is knowable in advance. A pipeline reading from an application database
and a pipeline reading from a log stream or an external service are not exposed to the same
corruption classes, and do not catch the same corruption at the same stage. The next lesson
separates the sources by their kind and prints two numbers for each source class: which
corruption classes it carries and the detection lag of each.
To keep your progress and take notes, Log in
My notes
Log in to take notes.