Lesson 05 / 18
Time Series Visualization
The same 36-period series is encoded at the same height with two axis scales: on the axis that starts from zero the reading resolution is 2.273 m³ and the mean read-back error per point is 0.540 m³, on the truncated axis 0.909 m³ and 0.246 m³. At a precision of 0.05 m³/period the slope is read back only from a window of 24 periods on the axis that starts from zero and from a window of 16 periods on the truncated axis. The seasonal component has a slope of its own and changes sign with the start of the window: the same 24-period length gives -0.2239, -0.0487 and -0.2260 m³/period in three separate places, while the trend put into the fictional series is -0.140.
Published Updated
In this lesson
- Builds the position channel on the time axis as an encoder and a decoder and writes the reading resolution as a number in rows
- Encodes the same series with two axis scales and compares the read-back error per point and the shortest window in which the slope can be read back
- Measures the slope of the seasonal component itself and shows that the trend is read back only after the component is separated
Contents
The previous four lessons encoded a number with four channels and measured what each reads back, all assuming a single snapshot. When the axis itself is time, the reader reads back not a value but the change of the value, and what is read is not a length but a slope.
The measure of this lesson is the read-back of the slope, and two questions are asked: what happens to the slope read back when the same series is encoded at the same height with two axis spans, and whether the trend can be read back without separating the seasonal component. The resampling decision was measured in the dataframes topic of the Data Analysis with Python course and is not repeated; the series is taken as given and only its display is measured.
- CD26. What is read on the time axis is the slope. The position difference between two points divided by the period difference gives the slope; the ratio of the length channel is not read here.
- CD27. The reading resolution is one row. The vertical extent of the display is divided into a finite number of distinguishable positions; nothing finer than one row can be told apart. The resolution is the axis span divided by the number of rows, and every run prints it.
- CD28. The read-back error of the slope shrinks with the window length. A slope read from the two ends errs by at most the resolution divided by the window length; in a short window the resolution swallows the slope.
- CD29. The seasonal component has a slope of its own. The component has a zero mean over the whole series, but in a finite window its least squares slope is not zero and changes sign with where the window starts. The reader reads a single slope, the sum of two components.
- CD30. The series is fictional. The three measured periods of the network cannot be extended to 36 periods; the series was derived with the same seed using the north region mean as the base, and its trend and seasonal amplitude are written in the code.
When the Axis Is a Time
# time_series.py — MODEL: the measurement network of K03-K05 is # regenerated with the same seed (3,199 rows, 1,329 subscribers, five regions). # The 36-period series is also FICTIONAL: the three measured periods of the # network cannot be extended to 36; the series was derived with the same seed # by taking the north mean as the base. The trend and the seasonal amplitude # put into it are constant. import math SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF PERIODS = ["2026-01", "2026-02", "2026-03"] REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26), ("west", 0.14, 14), ("central", 0.18, 23)] 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 def pick(u, w): t = 0.0 for i, x in enumerate(w): t += x if u < t: return i return len(w) - 1 _kept, _dropped = [], [] for i in range(RAW): r = rng(SEED + i) b = REGION[pick(r(), [x[1] for x in REGION])] pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05]) pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17]) (_dropped if r() < 0.046 else _kept).append( {"subscriber_id": 10001 + i, "region": b[0], "base": b[2]}) VALID = {a["subscriber_id"] for a in _kept} READINGS = [] for a in _kept + _dropped: r = rng(SEED + 7000 + a["subscriber_id"]) for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])): t = 0.0 if r() < 0.038 else math.floor( a["base"] * math.exp((r() + r() + r() - 1.5) * 0.62) * (1 - d * 0.05) * 100 + 0.5) / 100 if a["subscriber_id"] not in VALID: r() continue READINGS.append({"subscriber_id": a["subscriber_id"], "region": a["region"], "period": PERIODS[d], "consumption_m3": t}) r() north = [o["consumption_m3"] for o in READINGS if o["region"] == "north"] START = sum(north) / len(north) N, TREND, AMPLITUDE, SEASON = 36, -0.140, 2.20, 12 _g = rng(SEED + 505) SEASONAL = [AMPLITUDE * math.sin(2 * math.pi * t / SEASON) for t in range(N)] SERIES = [round(START + TREND * t + SEASONAL[t] + (_g() + _g() + _g() - 1.5) * 0.60, 3) for t in range(N)] def slope(v): """Least squares slope, unit: m3 / period.""" n = len(v) mt, mv = (n - 1) / 2, sum(v) / n return (sum((i - mt) * (x - mv) for i, x in enumerate(v)) / sum((i - mt) ** 2 for i in range(n))) def encode(v, low, high, rows): """Encoder: value -> position. Gives the exact row number on the grid.""" return max(0, min(rows - 1, round((v - low) / (high - low) * (rows - 1)))) def decode(k, low, high, rows): """Decoder: position -> value. The reader reads the value of the row itself.""" return low + k / (rows - 1) * (high - low) def draw(v, low, high, rows): pos = [encode(x, low, high, rows) for x in v] for s in range(rows - 1, -1, -1): print(f"{decode(s, low, high, rows):5.1f} |" + "".join("*" if k == s else " " for k in pos)) print(" +" + "-" * len(v)) return pos print(f"north region {len(north)} readings, mean {START:.3f} m3") print(f"fictional series: {N} periods, trend put in {TREND:+.3f} m3/period, " f"season length {SEASON} periods, amplitude {AMPLITUDE:.2f} m3") print(f"series minimum {min(SERIES):.2f}, maximum {max(SERIES):.2f} m3") print(f"series own slope {slope(SERIES):+.4f} m3/period")
north region 921 readings, mean 20.210 m3 fictional series: 36 periods, trend put in -0.140 m3/period, season length 12 periods, amplitude 2.20 m3 series minimum 13.00, maximum 22.02 m3 series own slope -0.1797 m3/period
The last two lines carry the tension of the lesson. The trend put into the fictional series is -0.140 m³ per period, written in the code; the slope of the series itself comes out as -0.1797. The 0.0397 difference comes not from the noise but from the slope of the seasonal component itself, printed separately in the third section. It appears before any display is built, at the level of numbers alone.
The Same Series, Two Axis Scales
# axis.py — the same series is encoded at the same height with two axis # scales. The reading resolution is one row: the reader cannot distinguish a # position finer than a row on the grid. ROWS, PRECISION = 12, 0.05 SOURCE = slope(SERIES) result = {} for name, low, high in (("zero-based", 0.0, 25.0), ("truncated", 13.0, 23.0)): c = (high - low) / (ROWS - 1) print(f"{name} axis: {low:.0f}-{high:.0f} m3, {ROWS} rows, " f"reading resolution {c:.3f} m3/row") pos = draw(SERIES, low, high, ROWS) back = [decode(k, low, high, ROWS) for k in pos] err = sum(abs(back[i] - SERIES[i]) for i in range(N)) / N print(f"mean read-back error of {N} points {err:.3f} m3; " f"the series uses {len(set(pos))} of the {ROWS} rows") result[name] = back print("") print(f"slope window zero-based truncated (window count) " f"precision {PRECISION:.2f} m3/period") for w in (4, 6, 9, 12, 15, 18, 24): count = [] for name in ("zero-based", "truncated"): g = result[name] count.append(sum(1 for i in range(N - w + 1) if abs(slope(g[i:i + w]) - slope(SERIES[i:i + w])) <= PRECISION)) print(f"{w:14d} {count[0]:9d} {count[1]:10d} {N - w + 1:18d}") for name in ("zero-based", "truncated"): g = result[name] full = [w for w in range(2, N + 1) if all(abs(slope(g[i:i + w]) - slope(SERIES[i:i + w])) <= PRECISION for i in range(N - w + 1))] shortest = next(w for w in full if all(v in full for v in range(w, N + 1))) print(f"on the {name} axis the slope is read back everywhere, at {PRECISION:.2f} " f"precision, only from a window of {shortest} periods")
zero-based axis: 0-25 m3, 12 rows, reading resolution 2.273 m3/row
25.0 |
22.7 | ** *
20.5 |* * * ***
18.2 | ** *** ** ******
15.9 | *** ***** **
13.6 | ****
11.4 |
9.1 |
6.8 |
4.5 |
2.3 |
0.0 |
+------------------------------------
mean read-back error of 36 points 0.540 m3; the series uses 5 of the 12 rows
truncated axis: 13-23 m3, 12 rows, reading resolution 0.909 m3/row
23.0 |
22.1 | *
21.2 | * ** *
20.3 |* * * *
19.4 | * * * *
18.5 | * * *
17.5 | * * * **
16.6 | *** * *
15.7 | * ** **
14.8 | *
13.9 | * **
13.0 | *
+------------------------------------
mean read-back error of 36 points 0.246 m3; the series uses 11 of the 12 rows
slope window zero-based truncated (window count) precision 0.05 m3/period
4 4 13 33
6 3 19 31
9 13 21 28
12 13 22 25
15 15 20 22
18 14 19 19
24 13 13 13
on the zero-based axis the slope is read back everywhere, at 0.05 precision, only from a window of 24 periods
on the truncated axis the slope is read back everywhere, at 0.05 precision, only from a window of 16 periods
The two displays encode the same 36 numbers at the same height of 12 rows; only the axis span differs. On the axis that starts from zero the series uses just 5 of the 12 rows, on the truncated axis 11. The mean read-back error per point is the numerical counterpart: 0.540 m³ against 0.246 m³. No number changed; the same numbers sat on a finer grid.
The second table is the real measure. The slope written into the report is wanted correct to a precision of 0.05 m³/period, a threshold chosen because it is a tenth of the total change over three years. In a four-period window the axis that starts from zero hits the precision in only 4 of 33 windows, the truncated axis in 13; at six periods the difference is sharpest, 3 against 19. Hitting the threshold in every window takes 24 periods on the axis that starts from zero and 16 periods on the truncated axis.
The basic charts lesson of the Analysis with Spreadsheets course measured axis truncation on the length channel, showing it detaches the bar ratio from the data. Here the same operation works in reverse: the length channel reads a ratio to the baseline, so moving the baseline breaks the ratio; the position channel reads the difference between two positions, independent of the baseline. The same decision taking the opposite sign in two channels shows that what decides is not the decision but the channel.
The Season’s Own Slope
# season.py — the seasonal component has a slope of its own, and it changes # sign according to where the window starts. step = [SERIES[i + 1] - SERIES[i] for i in range(N - 1)] print(f"of {len(step)} neighboring steps, {sum(1 for a in step if a > 0)} have a " f"sign opposite to the trend") print(f"{'window':12s}{'series slope':>15s}{'season slope':>16s}{'trend share':>13s}") for start in (0, 6, 12): p = slice(start, start + 24) print(f"{start:2d}-{start + 24:<2d} period {slope(SERIES[p]):+14.4f} " f"{slope(SEASONAL[p]):+15.4f} {TREND:+12.3f}") def smooth(v, p): """Centered moving average; for an even length the end values count as half.""" y = p // 2 return [(sum(v[i - y + 1:i + y]) + (v[i - y] + v[i + y]) / 2) / p for i in range(y, len(v) - y)] flat = smooth(SERIES, SEASON) flat_step = [flat[i + 1] - flat[i] for i in range(len(flat) - 1)] print(f"after the season is separated, {len(flat)} periods remain; " f"{sum(1 for a in flat_step if a > 0)} of the {len(flat_step)} steps are reversed") print(f"smoothed series slope {slope(flat):+.4f}, raw slope of the same window " f"{slope(SERIES[6:30]):+.4f}, trend put into the fictional series {TREND:+.3f}")
of 35 neighboring steps, 17 have a sign opposite to the trend window series slope season slope trend share 0-24 period -0.2239 -0.0857 -0.140 6-30 period -0.0487 +0.0857 -0.140 12-36 period -0.2260 -0.0857 -0.140 after the season is separated, 24 periods remain; 0 of the 23 steps are reversed smoothed series slope -0.1347, raw slope of the same window -0.0487, trend put into the fictional series -0.140
The first line gives the display’s most misleading side: 17 of the 35 neighboring steps point against the trend. A reader who reads direction from two neighboring periods flips a coin.
The middle table separates the cause. All three windows are 24 periods long and carry the same trend, but the slope of the series comes out as -0.2239, -0.0487 and -0.2260. The third column gives the source of the difference: the least squares slope of the seasonal component itself is -0.0857 in the window that starts from period 0 and +0.0857 in the one that starts from period 6. The component has a zero mean over the whole series yet contributes to the slope in a finite window, and its sign depends on where the window starts within the season.
This is a property of the series, not of the display; no axis setting can correct it, so the separation must come before the display. The last two lines are its result: with a moving average centered at the season length, none of the 23 steps keeps a reversed sign, and the slope read back becomes -0.1347. The raw slope of the same window was -0.0487; smoothing removed a deviation of 0.0913. The price is 12 periods, six dropped from each end of the series.
Resolution and Season Bind Together
# both.py — the resolution and the season bind not separately but together. print("window axis only season only both together") trunc = result["truncated"] td = smooth(trunc, SEASON) sd = smooth(SERIES, SEASON) for w in (4, 6, 9, 12, 18): a = sum(1 for i in range(N - w + 1) if abs(slope(trunc[i:i + w]) - TREND) <= PRECISION) / (N - w + 1) b = sum(1 for i in range(len(sd) - w + 1) if abs(slope(sd[i:i + w]) - TREND) <= PRECISION) / (len(sd) - w + 1) c = sum(1 for i in range(len(td) - w + 1) if abs(slope(td[i:i + w]) - TREND) <= PRECISION) / (len(td) - w + 1) print(f"{w:7d} {a * 100:14.1f}% {b * 100:15.1f}% {c * 100:14.1f}%") print(f"trend in the source {TREND:+.3f} m3/period; the rates above are how often " f"the window gives this number at {PRECISION:.2f} precision")
window axis only season only both together
4 3.0% 100.0% 85.7%
6 0.0% 100.0% 89.5%
9 7.1% 100.0% 100.0%
12 4.0% 100.0% 100.0%
18 100.0% 100.0% 100.0%
trend in the source -0.140 m3/period; the rates above are how often the window gives this number at 0.05 precision
The previous table compared the slope read back with the slope of the window itself; this one compares it with the trend put into the fictional series, and its columns correspond to three separate corrections.
The first column shows the insufficiency of the axis setting: the truncated axis does not give the trend in any window of six periods, and in a nine-period window gives it in two of 28 windows. The axis resolution corrects the positions of the points, but not the share the season adds to the slope. The second column shows the opposite: when the season is separated, even a four-period window hits the precision. The third column writes the price of the two together — the series encoded on the truncated axis and then smoothed starts at 85.7% and gives 100% from nine periods on. The remaining shortfall comes from quantization: every point is rounded to the row grid, and in a short window this rounding reaches the slope even after smoothing.
The decision rule comes from here: a time series display cannot be reported without the reading resolution of the axis and the window length over which the slope is read written next to it. If either is missing, the slope the reader sees is not the trend in the data.
Summary
- What is read on the time axis is not length but slope; the reading resolution is one row, written as the axis span divided by the number of rows.
- With the same series at the same height and two axis spans, the mean read-back error per point is 0.540 m³ and 0.246 m³; the axis that starts from zero uses 5 of the 12 rows.
- At a precision of 0.05 m³/period the slope is read back in every window only from 24 periods on the axis that starts from zero and 16 periods on the truncated axis.
- The seasonal component’s own slope changes sign between -0.0857 and +0.0857 with the start of the window; the same 24-period length gives -0.2239, -0.0487 and -0.2260 m³/period in three places.
- When the season is separated with a centered moving average, none of the 23 steps is left reversed and the slope read back becomes -0.1347; the price is 12 periods, and axis truncation cannot do this alone.
Next Step
This lesson turned the axis into time and measured that the channel carries change. Every reading of the measurement network is also taken at a place, a two-dimensional area. The next lesson models the regions as a grid and measures that a painted-area display reads back not a value but an area: the small value of a large region suppresses the large value of a small region, and the error is counted in proportion to the number of cells the region covers. Printing the same grid on a plane stretches it, and the stretching moves the number read back a third time.
To keep your progress and take notes, Log in
My notes
Log in to take notes.