vanzelfsprekend

The labour-income decomposition

Put the components on one scale so they can be compared, then let the ticks carry what the decomposition found.

A simulated monthly labour income, split into a permanent component and a transient residual. The permanent component is recovered by penalized median segmentation, a piecewise-constant fit whose segments take their own median; the transient component is what is left. The decomposition is inspired by a working paper on consumption responses to labour income changes,Kris Boudt, Koen Schoors, Milan van den Heuvel and Johannes Weytjens, Taming the Zoo of Consumption Responses to Labour Income Changes, Ghent University Working Paper 2023/1067, April 2023. simplified here to a clean example. The recurrent June holiday pay and December bonuses fall in the transient component; a one-off raise steps the permanent one. This lesson is about the graphics that make those readable, and it builds the figure one call at a time, as the grand tours did; the script is examples/tutorial_income.py.

The decomposition, in default boxes

fig, axes = plt.subplots(3, 1, figsize=(5.6, 6.2))
fig.subplots_adjust(hspace=0.55)
a_income, a_perm, a_nu = axes

grey = {"color": vzs.palettes.LINE_INK, "marker": "o", "markersize": 2.2}
a_income.plot(dates, income, linewidth=0.8, **grey)
a_perm.plot(dates, perm, linewidth=0.8, **grey)
a_nu.plot(dates, nu, linewidth=0.8, **grey)

band = {"color": vzs.palettes.LINE_INK, "linewidth": 0.7, "linestyle": (0, (3, 3))}
a_nu.plot(dates, C * sigma, **band)
a_nu.plot(dates, -C * sigma, **band)
2026-09-14T23:22:10.803428 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/

Step one. Three boxes: labour income, the permanent step and the transient residual. Each panel sets its own y range, so the permanent step floats on a scale of its own and cannot be read against the income it came from.

The three series are drawn and nothing reads across them. The permanent panel autoscales to its own two levels, which fills the box but says nothing about how big the raise is against the income around it. A decomposition you cannot compare across is three plots, not one figure.

The comparison the decomposition needs

vzs.small_multiples(
    [a_income, a_perm],
    compare="figure",
    frame=(("data", "loose"), "loose"),
    spacing=(6, 4),
)
vzs.apply(a_nu, frame=(("data", "loose"), "loose"), spacing=(6, 4))
vzs.ylabel(a_income, "Labour income")
vzs.ylabel(a_perm, "Permanent component")
vzs.ylabel(a_nu, "Transient component")
2026-09-14T23:22:11.063765 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/

Step two. Income and the permanent component share one scale, so the raise reads against income's own variation; the transient stands on its own below. Each box drops to two spines and each y label sits at the top of its spine.

small_multiples frames income and the permanent component together, and compare="figure" puts them on one shared scale. The permanent step is now the right size on the page: the raise reads against the month-to-month swing of the income above it, which is the comparison the decomposition was for. The transient residual is a different quantity on a different scale, so it takes its own apply and stands alone. Every panel takes the same frame, (("data", "loose"), "loose"): the x starts firm at the first month of 2012 and runs loose to a round year at the open end, and the y runs out to round numbers.

Mark what the decomposition surfaced

a_perm.scatter(
    dnum[raise_pts], perm[raise_pts], s=24, color=RAISE_C, zorder=5, label="raise"
)
a_nu.scatter(
    dnum[jun], nu[jun], s=14, color=HOLIDAY_C, zorder=5, label="holiday pay"
)
a_nu.scatter(dnum[dec], nu[dec], s=14, color=BONUS_C, zorder=5, label="bonus")

recur = vzs.FeatureLocator(
    date2num(dates), nu, {"Jun": date2num(HOLIDAY_AT), "Dec": date2num(BONUS_AT)}
)
a_nu.xaxis.set_major_locator(
    vzs.AugmentedLocator(a_nu.xaxis.get_major_locator(), recur)
)
a_nu.yaxis.set_major_locator(
    vzs.AugmentedLocator(a_nu.yaxis.get_major_locator(), [0.0])
)
a_perm.yaxis.set_major_locator(
    vzs.AugmentedLocator(a_perm.yaxis.get_major_locator(), np.unique(perm).tolist())
)
for panel in axes:
    panel.yaxis.set_major_formatter("{x:.2f}")
2026-09-14T23:22:11.355036 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/

Step three. The ticks now carry the decomposition's own numbers: the permanent panel at its two recovered levels, the transient at zero and at the months it recurs on. The bumps and the raise are marked where they happen.

Decomposing the series produced particular numbers, and those are what the axis should carry. AugmentedLocator puts them there without giving up the round-number ticks, by unioning the frame's nice ticks with positions you supply. The permanent y gains its two recovered levels, so the raise is the gap between two ticks a reader can read straight off. The transient y gains a zero line, and its x gains the June and December the bumps fall on, named as features so the next step can pick them out. FeatureLocator carries those named positions; set_major_formatter("{x:.2f}") rounds each panel's y labels, and where a level tick lands beside a round one the two labels separate on their own.

Accent the levels and the recurrence

a_nu.vzs.accent(axis="x", label="name", color={"Jun": HOLIDAY_C, "Dec": BONUS_C})
a_perm.vzs.accent(axis="y", at=np.unique(perm).tolist(), color=RAISE_C)
2026-09-14T23:22:11.715860 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/

Step four. The two permanent levels take the raise's blue and the June and December ticks take the bumps' gold and red, so colour alone ties each tick to the point it stands for.

accent colours the ticks you point it at, by name or by value. On the transient x it takes the named June and December and colours them gold and red, the colours of the bumps; on the permanent y it takes the two levels by value and colours them the raise's blue, so the step's before and after read as one blue pair with the raise point between them. An axis mark and the point it stands for now read as one thing, and the plot carries no text of its own: the colour is the key, and it is data rather than decoration, holiday pay and the year-end bonus being two kinds of recurrent income.The median cost in the segmentation is what lets the recurrence stay in the transient panel at all. A June bump is larger than the raise but rare within any run of months, so a segment's median steps over it; a least-squares fit would chase the bumps and break the permanent level into steps that were never there.

What the frame says without a caption

The permanent panel answers "how big was the raise?" with two ticks: 8.51 before, 8.76 after, and the distance between them is the answer. Because that panel shares income's scale, the same raise is visibly small against income's month-to-month swing, so "large or small?" is answered on the page as well as in the number.

The transient panel answers "when does income jump, and by how much?" The zero line is the baseline, the dashed band is the threshold a bump has to clear to count, and the coloured points are the months that clear it. Read down the year axis and the recurrence is plain: a gold June and a red December, every year, in the residual rather than the trend.

Going further

small_multiples set the shared scale here; its how-to has the three compare scopes. The union of nice ticks with your own is AugmentedLocator and the colouring is accent, both in the locators reference; the locators how-to covers setting your own ticks in general. The gallery has the finished figure among the rest.