Dictionary of Applied Machine Learning · pretraining

pretraining — Python demo

Updated on 2026-11-03

Numerical companion to the entry pretraining: it recomputes what the entry states and prints one line per check

Numerical companion to the glossary entry 'pretraining'. A 2.4 x 1.2 km piece of the aerial photograph of the Wachau (assets/ wachau_ortho_center.jpg: the Danube bend at Duernstein, 0.79 m per pixel; orthophoto (c) basemap.at, CC BY 4.0) is cut into 4608 square patches of 32 px (25 m), and the parcel mask assets/ wachau_labels_center.png (INVEKOS Schlaege 2025-1, AgrarMarkt Austria, CC BY 3.0 AT) labels a patch as vineyard when more than half of its pixels lie on vineyard parcels. The western half of the piece is the training set, the eastern half the test set. At this resolution the rows of vines are visible stripes, and the mean color of a patch tells little: vineyards, meadows and gardens are all green.

Run it without installing anything:
uv run https://dictionaryofml.org/terms/pretraining.py
uv downloads this script and the pinned NumPy and Matplotlib it needs, then runs it; the script fetches any input file it uses. To keep the output files, download pretraining.py into a folder and run uv run pretraining.py there. With NumPy and Matplotlib already installed, python3 pretraining.py, from any directory — it writes its output files into the current directory. Fixed seeds, so the printed numbers reproduce exactly. Download pretraining.py · Notebook · Open in Colab

Before running a downloaded copy, scan it: uvx bandit -q -t B102,B307,B602,B605 pretraining.py (B102,B307,B602,B605 are the exec, eval and shell checks of bandit) reports nothing: the script runs no shell command and evaluates no generated code.

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""Pretraining the first layers of a convolutional network on a pretext
task without labels, then reusing them as frozen features for a linear
classifier of vineyard patches.

Purpose
-------
Numerical companion to the glossary entry 'pretraining'.  A 2.4 x 1.2 km
piece of the aerial photograph of the Wachau (assets/
wachau_ortho_center.jpg: the Danube bend at Duernstein, 0.79 m per
pixel; orthophoto (c) basemap.at, CC BY 4.0) is cut into 4608 square
patches of 32 px (25 m), and the parcel mask assets/
wachau_labels_center.png (INVEKOS Schlaege 2025-1, AgrarMarkt Austria,
CC BY 3.0 AT) labels a patch as vineyard when more than half of its
pixels lie on vineyard parcels.  The western half of the piece is the
training set, the eastern half the test set.  At this resolution the
rows of vines are visible stripes, and the mean color of a patch tells
little: vineyards, meadows and gardens are all green.

Pretraining needs no labels.  Two pretext tasks are run on the training
patches, each with the same network: two convolutional layers (3 x 3
filters, 8 channels each, ReLU) and a linear readout.  The first blanks
the central 8 x 8 pixel block of a patch and predicts the mean color of
the blanked pixels from their surroundings.  The second rotates a patch
by 0, 90, 180 or 270 degrees and predicts the rotation, which is
answered by the direction of the rows and of the shadows.  Only the
photograph is used, never the parcel mask.

Transfer.  The two convolutional layers are then frozen and applied to
the original patches; their output, average-pooled to a 4 x 4 grid,
gives 128 features per patch.  A linear classifier (logistic regression
with a small ridge penalty) is trained on these features of as few as 8
and at most 128 labeled western patches and evaluated on the eastern
patches.  Labels are scarce on purpose: that is the regime in which
pretraining decides the outcome, and in which a classifier on the pixels
themselves, which has more features than labeled patches, overfits to no
better than the majority baseline.  The
comparison: the same linear classifier on the mean color of the patch
(3 features), on the pixels pooled to 16 x 16 (768 features), and on the
features of
the same two layers with their random initial weights, i.e., without
pretraining; and the majority baseline.  The last comparison is the
control: the color-pretrained layers are ahead of the untrained ones from
32 labels on, and the rotation-pretrained layers beat them at every
number of labels.

Deterministic: fixed seeds for the initial weights and the patches of each step.
Self-contained: numpy + matplotlib only.

Blocks
------
[B-patches]  Cut the photograph into 4608 patches of 32 px (25 m), label
             them by the parcel mask, and split them by easting into a
             western training half and an eastern test half.
[B-pretrain] Blank the center of every training patch and train the two
             convolutional layers plus a linear readout to predict the
             center color (Adam, 512 patches per step).  Check that the
             loss falls and that the trained network beats the patch's
             mean color as a prediction of the center on the test half.
[B-rotation] The second pretext task: predict the rotation of a patch
             (Adam, 512 patches per step).  Check that the rotation is
             recognized well above chance on the test half.
[B-transfer] Freeze the pretrained layers, pool their output to 128
             features, and fit a linear classifier of vineyard vs. no
             vineyard on 8 to 128 labeled patches of the training half; compare its test accuracy with the same classifier on
             mean color, on raw pixels and on the features of the
             untrained layers, and with the majority baseline.
[B-plot]     Write the CSV files and images for the entry's figures and
             the preview, among them the photograph beside its parcel
             register.

Outputs
-------
pretraining_loss.csv : iteration, train, test -- the color pretext loss
                       (mean squared error of the predicted center
                       color, in units of the pixel range [0, 1]) on the
                       two halves
pretraining_accuracy.csv : labels, meancolor, rawpixels, untrained,
                       color, rotation, baseline -- the test accuracy of
                       the linear classifier on each feature set against
                       the number of labeled patches (mean over up to
                       five spread-out subsets), and the majority
                       baseline
pretraining_scene.png : the piece of the photograph and the parcel
                       register beside it, with the western training
                       half and the eastern test half marked
pretraining_patches.png : eight test patches: the blanked input, the
                       network's prediction of the center painted in,
                       and the original
pretraining.png : preview (checking only)
"""

# /// script
# requires-python = ">=3.10"
# dependencies = ["matplotlib==3.10.7", "numpy==2.3.4"]
# ///

import math
from pathlib import Path

import urllib.request

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.image import imread

OUT_DIR = Path(__file__).parent

ASSET_URL = "https://dictionaryofml.org/demoassets/"


def demo_asset(name):
    """Path to the input file `name`, fetched once if it is not already here.

    Inside the repository the committed copy is used (pythondemos/ first,
    then assets/), so a run there reproduces the committed outputs exactly.
    Run from anywhere else -- from the copy published on dictionaryofml.org,
    say -- the file is downloaded and cached beside the script, so the demo
    is runnable without the repository.
    """
    for cand in (OUT_DIR / name, OUT_DIR.parent / "assets" / name):
        if cand.exists():
            return cand
    cached = OUT_DIR / name
    print(f"    fetching {name} from {ASSET_URL}{name}")
    with urllib.request.urlopen(ASSET_URL + name, timeout=180) as resp:
        cached.write_bytes(resp.read())
    return cached

report = []                         # collects (check name, pass/fail) pairs


def check(name, ok):                # records and prints one verification
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")
  4608 patches of 48 x 96, 32 x 32 px each; vineyard share 0.326 (west 0.343, east 0.308)

B-patches

Cut the photograph into 4608 patches of 32 px (25 m), label them by the parcel mask, and split them by easting into a western training half and an eastern test half.

SIDE = 32                           # 32 px = 25 m on the ground
photo = imread(demo_asset("wachau_ortho_center.jpg")) / 255.0
labels = np.asarray(imread(demo_asset("wachau_labels_center.png")))
if labels.ndim == 3:
    labels = labels[:, :, 0]
if labels.max() <= 1.0 and labels.dtype != np.uint8:
    labels = np.rint(labels * 255).astype(np.uint8)
rows, cols = photo.shape[0] // SIDE, photo.shape[1] // SIDE
patches = photo[:rows * SIDE, :cols * SIDE].reshape(rows, SIDE, cols, SIDE, 3)
patches = patches.transpose(0, 2, 1, 3, 4).reshape(rows * cols, SIDE, SIDE, 3).astype(np.float32)
share = (labels[:rows * SIDE, :cols * SIDE] == 1).reshape(
    rows, SIDE, cols, SIDE).mean(axis=(1, 3)).ravel()
y = (share > 0.5).astype(np.float32)                  # 1 = vineyard
grid_c = np.arange(rows * cols) % cols
train = grid_c < cols // 2                            # western half
test = ~train
mean_color = patches[train].mean(axis=(0, 1, 2))      # training statistics only
X_img = patches - mean_color                          # centered pixels
print(f"  {len(y)} patches of {rows} x {cols}, {SIDE} x {SIDE} px each; vineyard share "
      f"{y.mean():.3f} (west {y[train].mean():.3f}, east {y[test].mean():.3f})")
check("[B-patches] 4608 patches, split into halves of equal size by easting",
      len(y) == 4608 and train.sum() == test.sum() == 2304)
check("[B-patches] between a fifth and two fifths of the patches are vineyard",
      0.2 < y.mean() < 0.4)
  [ok] [B-patches] 4608 patches, split into halves of equal size by easting
  [ok] [B-patches] between a fifth and two fifths of the patches are vineyard
  iteration   1: color pretext loss train 0.00621, test 0.01020
  iteration  50: color pretext loss train 0.00058, test 0.00105
  iteration 100: color pretext loss train 0.00046, test 0.00086
  iteration 150: color pretext loss train 0.00040, test 0.00078
  iteration 200: color pretext loss train 0.00036, test 0.00074
  iteration 250: color pretext loss train 0.00032, test 0.00072
  iteration 300: color pretext loss train 0.00030, test 0.00076
  center color on the test half: mean squared error of the network 0.00076, of the patch's mean color outside the blank 0.00296

B-pretrain

Blank the center of every training patch and train the two convolutional layers plus a linear readout to predict the center color (Adam, 512 patches per step). Check that the loss falls and that the trained network beats the patch's mean color as a prediction of the center on the test half.

rng = np.random.default_rng(0)
CH = 8                              # channels of both convolutional layers
C0, C1 = 12, 20                     # the blanked center block: rows and columns 12..19
ITERS, BATCH, LR, B1, B2 = 300, 512, 3e-3, 0.9, 0.999


def conv_cols(x):
    """3 x 3 neighborhoods of every pixel (zero padding): (N, H, W, 9 C)."""
    n, h, w, c = x.shape
    xp = np.pad(x, ((0, 0), (1, 1), (1, 1), (0, 0)))
    return np.concatenate([xp[:, i:i + h, j:j + w, :] for i in range(3) for j in range(3)],
                          axis=3)


def conv_cols_back(dcols, shape):
    """Adjoint of conv_cols: scatter the neighborhood gradients back to pixels."""
    n, h, w, c = shape
    dxp = np.zeros((n, h + 2, w + 2, c), dtype=np.float32)
    for k, (i, j) in enumerate((i, j) for i in range(3) for j in range(3)):
        dxp[:, i:i + h, j:j + w, :] += dcols[..., k * c:(k + 1) * c]
    return dxp[:, 1:-1, 1:-1, :]


def init_params(n_in, n_out):
    """He initialization of the two convolutional layers and a linear readout."""
    w1 = (rng.standard_normal((27, CH)) * math.sqrt(2.0 / 27)).astype(np.float32)
    w2 = (rng.standard_normal((9 * CH, CH)) * math.sqrt(2.0 / (9 * CH))).astype(np.float32)
    wh = (rng.standard_normal((n_in * CH, n_out)) * math.sqrt(1.0 / (n_in * CH))).astype(np.float32)
    return {"w1": w1, "b1": np.zeros(CH, np.float32), "w2": w2,
            "b2": np.zeros(CH, np.float32), "wh": wh, "bh": np.zeros(n_out, np.float32)}


def layers(p, x):
    """The two convolutional layers with ReLU: their output (N, 32, 32, CH)."""
    c1 = conv_cols(x)
    a1 = np.maximum(c1 @ p["w1"] + p["b1"], 0.0)
    c2 = conv_cols(a1)
    a2 = np.maximum(c2 @ p["w2"] + p["b2"], 0.0)
    return a2, (c1, a1, c2)


def layers_back(p, da2, saved, grads):
    """Backpropagate a gradient at the layers' output into their weights."""
    c1, a1, c2 = saved
    grads["w2"] = c2.reshape(-1, 9 * CH).T @ da2.reshape(-1, CH)
    grads["b2"] = da2.sum((0, 1, 2))
    da1 = conv_cols_back(da2 @ p["w2"].T, a1.shape)
    da1 *= a1 > 0
    grads["w1"] = c1.reshape(-1, 27).T @ da1.reshape(-1, CH)
    grads["b1"] = da1.sum((0, 1, 2))
    return grads


def adam_step(p, grads, moments, step):
    for k in p:
        m, v = moments[k]
        m[...] = B1 * m + (1 - B1) * grads[k]
        v[...] = B2 * v + (1 - B2) * grads[k] ** 2
        p[k] -= LR * (m / (1 - B1 ** step)) / (np.sqrt(v / (1 - B2 ** step)) + 1e-8)


def color_forward(p, x):
    a2, saved = layers(p, x)
    block = a2[:, C0:C1, C0:C1, :].reshape(len(x), -1)
    return block @ p["wh"] + p["bh"], (a2, block, saved)


def color_backward(p, target, out, cache):
    """Gradients of the mean squared error of the center color."""
    a2, block, saved = cache
    n = len(out)
    dout = 2.0 * (out - target) / (n * 3)
    grads = {"wh": block.T @ dout, "bh": dout.sum(0)}
    da2 = np.zeros_like(a2)
    da2[:, C0:C1, C0:C1, :] = (dout @ p["wh"].T).reshape(n, C1 - C0, C1 - C0, CH)
    da2 *= a2 > 0
    return layers_back(p, da2, saved, grads)


def blank(x):
    """The pretext input: the center block set to zero (the mean color)."""
    xb = x.copy()
    xb[:, C0:C1, C0:C1, :] = 0.0
    return xb


def in_slices(fn, x, size=256):
    """Apply fn to x in slices, so the 9-fold neighborhood expansion inside
    the layers is held for a few hundred patches at a time rather than for
    all of them."""
    return np.concatenate([fn(x[i:i + size]) for i in range(0, len(x), size)])


X_blank = blank(X_img)
target = X_img[:, C0:C1, C0:C1, :].mean(axis=(1, 2))   # the color to predict
params_color = init_params((C1 - C0) ** 2, 3)
params_random = {k: v.copy() for k, v in params_color.items()}   # the untrained control
moments = {k: (np.zeros_like(v), np.zeros_like(v)) for k, v in params_color.items()}
train_idx = np.flatnonzero(train)
loss_log = []
for step in range(1, ITERS + 1):
    sel = np.sort(rng.choice(train_idx, BATCH, replace=False))
    out, cache = color_forward(params_color, X_blank[sel])
    adam_step(params_color, color_backward(params_color, target[sel], out, cache), moments, step)
    if step == 1 or step % 50 == 0:
        err = [float(((in_slices(lambda z: color_forward(params_color, z)[0], X_blank[part])
                        - target[part]) ** 2).mean()) for part in (train, test)]
        loss_log.append((step, err[0], err[1]))
        print(f"  iteration {step:3d}: color pretext loss train {err[0]:.5f}, "
              f"test {err[1]:.5f}")
mse_net = loss_log[-1][2]
ring_mean = X_blank[test].sum(axis=(1, 2)) / (SIDE * SIDE - (C1 - C0) ** 2)
mse_ring = float(((ring_mean - target[test]) ** 2).mean())
print(f"  center color on the test half: mean squared error of the network {mse_net:.5f}, "
      f"of the patch's mean color outside the blank {mse_ring:.5f}")
check("[B-pretrain] the color pretext loss on the training half falls to below a "
      "tenth of its initial value", loss_log[-1][1] < 0.1 * loss_log[0][1])
check("[B-pretrain] on the test half the network predicts the center color better "
      "than the patch's mean color outside the blank", mse_net < mse_ring)
  [ok] [B-pretrain] the color pretext loss on the training half falls to below a tenth of its initial value
  [ok] [B-pretrain] on the test half the network predicts the center color better than the patch's mean color outside the blank
  iteration   1: rotation recognized in 0.281 of the step's patches
  iteration 100: rotation recognized in 0.357 of the step's patches
  iteration 200: rotation recognized in 0.449 of the step's patches
  iteration 300: rotation recognized in 0.459 of the step's patches
  rotation recognized in 0.368 of the rotated test patches (chance 0.25)

B-rotation

The second pretext task: predict the rotation of a patch (Adam, 512 patches per step). Check that the rotation is recognized well above chance on the test half.

def pool4(a2):
    """Average-pool the layers' output to a 4 x 4 grid: 128 numbers per patch."""
    n = len(a2)
    return a2.reshape(n, 4, SIDE // 4, 4, SIDE // 4, CH).mean(axis=(2, 4)).reshape(n, -1)


def rotation_forward(p, x):
    a2, saved = layers(p, x)
    pooled = pool4(a2)
    return pooled @ p["wh"] + p["bh"], (a2, pooled, saved)


def rotation_backward(p, target, logits, cache):
    """Gradients of the cross-entropy of the four rotations."""
    a2, pooled, saved = cache
    n = len(logits)
    q = np.exp(logits - logits.max(1, keepdims=True))
    q /= q.sum(1, keepdims=True)
    dout = (q - np.eye(4, dtype=np.float32)[target]) / n
    grads = {"wh": pooled.T @ dout, "bh": dout.sum(0)}
    dpool = (dout @ p["wh"].T).reshape(n, 4, 1, 4, 1, CH) / (SIDE // 4) ** 2
    da2 = np.broadcast_to(dpool, (n, 4, SIDE // 4, 4, SIDE // 4, CH)).reshape(a2.shape).copy()
    da2 *= a2 > 0
    return layers_back(p, da2, saved, grads)


def rotations(x):
    """Every patch in the four rotations, with the rotation as label."""
    return (np.concatenate([np.rot90(x, k, axes=(1, 2)) for k in range(4)]),
            np.repeat(np.arange(4), len(x)))


X_rot, rot_label = rotations(X_img[train])
params_rot = init_params(16, 4)
moments = {k: (np.zeros_like(v), np.zeros_like(v)) for k, v in params_rot.items()}
for step in range(1, ITERS + 1):
    sel = np.sort(rng.choice(len(X_rot), BATCH, replace=False))
    logits, cache = rotation_forward(params_rot, X_rot[sel])
    adam_step(params_rot, rotation_backward(params_rot, rot_label[sel], logits, cache),
              moments, step)
    if step == 1 or step % 100 == 0:
        print(f"  iteration {step:3d}: rotation recognized in "
              f"{(logits.argmax(1) == rot_label[sel]).mean():.3f} of the step's patches")
X_rot_test, rot_label_test = rotations(X_img[test])
rot_acc = float((in_slices(lambda z: rotation_forward(params_rot, z)[0], X_rot_test).argmax(1)
                 == rot_label_test).mean())
print(f"  rotation recognized in {rot_acc:.3f} of the rotated test patches (chance 0.25)")
check("[B-rotation] the rotation of a test patch is recognized in at least 35% of "
      "the cases, against 25% by chance", rot_acc >= 0.35)
  [ok] [B-rotation] the rotation of a test patch is recognized in at least 35% of the cases, against 25% by chance
     8 labeled patches: test accuracy mean color 0.721, raw pixels 0.642, untrained layers 0.717, color-pretrained layers 0.726, rotation-pretrained layers 0.765
    16 labeled patches: test accuracy mean color 0.702, raw pixels 0.656, untrained layers 0.738, color-pretrained layers 0.742, rotation-pretrained layers 0.781
    32 labeled patches: test accuracy mean color 0.676, raw pixels 0.668, untrained layers 0.729, color-pretrained layers 0.756, rotation-pretrained layers 0.787
    64 labeled patches: test accuracy mean color 0.661, raw pixels 0.676, untrained layers 0.743, color-pretrained layers 0.764, rotation-pretrained layers 0.812
   128 labeled patches: test accuracy mean color 0.668, raw pixels 0.719, untrained layers 0.764, color-pretrained layers 0.784, rotation-pretrained layers 0.818
  majority baseline (always 'no vineyard'): test accuracy 0.692

B-transfer

Freeze the pretrained layers, pool their output to 128 features, and fit a linear classifier of vineyard vs. no vineyard on 8 to 128 labeled patches of the training half; compare its test accuracy with the same classifier on mean color, on raw pixels and on the features of the untrained layers, and with the majority baseline.

def logistic_regression(x_tr, y_tr, ridge=1e-2, iters=30):
    """Logistic regression with a ridge penalty on standardized features.

    With fewer labeled patches than features the Newton step is taken in the
    span of the patches instead of in feature space.  Writing the scaled
    design as U S V^T, the weights that minimize the penalized loss are V a
    for some a, and the penalty is |a|^2 because V has orthonormal columns,
    so fitting on U S is the same problem in as many unknowns as there are
    patches: a 129 x 129 solve rather than a 769 x 769 one."""
    mu, sd = x_tr.mean(0), x_tr.std(0) + 1e-6                # training statistics
    scaled = ((x_tr - mu) / sd).astype(np.float64)
    basis = None
    if scaled.shape[1] > len(scaled):
        left, sing, right = np.linalg.svd(scaled, full_matrices=False)
        scaled, basis = left * sing, right
    z = np.column_stack([np.ones(len(scaled)), scaled])
    w = np.zeros(z.shape[1])
    reg = ridge * np.eye(z.shape[1])
    reg[0, 0] = 0.0
    for _ in range(iters):
        q = 1.0 / (1.0 + np.exp(-z @ w))
        grad = z.T @ (q - y_tr) / len(z) + reg @ w
        hess = (z * (q * (1 - q))[:, None]).T @ z / len(z) + reg
        w -= np.linalg.solve(hess, grad)
    offset, weights = w[0], (w[1:] @ basis if basis is not None else w[1:])
    return lambda x: (offset + (x - mu) / sd @ weights > 0).astype(np.float32)


def frozen_features(p):
    return in_slices(lambda z: pool4(layers(p, z)[0]), X_img)


# the pixels themselves, pooled 2 x 2 to 16 x 16: a linear classifier on all
# 3072 pixels needs a 3073 x 3073 Newton solve per step, minutes per fit, and
# scores the same as this cheaper version of the same baseline
feature_sets = {
    "mean color": patches.mean(axis=(1, 2)),
    "raw pixels": X_img.reshape(len(y), 16, 2, 16, 2, 3).mean(axis=(2, 4)).reshape(len(y), -1),
    "untrained layers": frozen_features(params_random),
    "color-pretrained layers": frozen_features(params_color),
    "rotation-pretrained layers": frozen_features(params_rot),
}
majority = 1.0 if y[train].mean() >= 0.5 else 0.0    # the more frequent label
acc_base = float((majority == y[test]).mean())
# Few labels on purpose.  With a hundred labeled patches or fewer the
# classifier cannot learn what the pretext task already taught the layers,
# which is the regime where pretraining decides the outcome; with a
# thousand it no longer matters what the layers were initialized with.
SIZES = (8, 16, 32, 64, 128)                          # labeled patches used
acc = {name: [] for name in feature_sets}             # test accuracy against the size
for m in SIZES:
    stride = len(train_idx) // m
    for name, f in feature_sets.items():
        runs = []
        for offset in range(min(10, stride)):         # up to ten spread-out subsets
            idx = train_idx[offset::stride][:m]
            predict = logistic_regression(f[idx], y[idx])
            runs.append(float((predict(f[test]) == y[test]).mean()))
        acc[name].append(float(np.mean(runs)))
    print(f"  {m:4d} labeled patches: test accuracy "
          + ", ".join(f"{name} {acc[name][-1]:.3f}" for name in feature_sets))
print(f"  majority baseline (always 'no vineyard'): test accuracy {acc_base:.3f}")
check("[B-transfer] a linear classifier on the raw pixels, which has more "
      "features than labeled patches, is no better than answering with the "
      "majority at the smallest label counts",
      all(a <= acc_base for a in acc["raw pixels"][:3]))
check("[B-transfer] the rotation-pretrained layers beat the untrained ones at every "
      "number of labeled patches, and are never below the color-pretrained ones",
      all(r > u and r >= c for r, u, c in zip(acc["rotation-pretrained layers"],
                                              acc["untrained layers"],
                                              acc["color-pretrained layers"])))
check("[B-transfer] from sixteen labeled patches on, both pretrained layer "
      "sets beat mean color and raw pixels",
      all(min(c, r) > max(a, b) for c, r, a, b in
          zip(acc["color-pretrained layers"][1:], acc["rotation-pretrained layers"][1:],
              acc["mean color"][1:], acc["raw pixels"][1:])))
check("[B-transfer] the mean color of a patch stays within 0.05 of the majority "
      "baseline at every number of labeled patches: color alone does not tell a "
      "vineyard", all(abs(a - acc_base) <= 0.05 for a in acc["mean color"]))
  [ok] [B-transfer] a linear classifier on the raw pixels, which has more features than labeled patches, is no better than answering with the majority at the smallest label counts
  [ok] [B-transfer] the rotation-pretrained layers beat the untrained ones at every number of labeled patches, and are never below the color-pretrained ones
  [ok] [B-transfer] from sixteen labeled patches on, both pretrained layer sets beat mean color and raw pixels
  [ok] [B-transfer] the mean color of a patch stays within 0.05 of the majority baseline at every number of labeled patches: color alone does not tell a vineyard

B-plot

Write the CSV files and images for the entry's figures and the preview, among them the photograph beside its parcel register.

with open(OUT_DIR / "pretraining_loss.csv", "w") as f:
    f.write("iteration,train,test\n")
    for step, tr, te in loss_log:
        f.write(f"{step},{tr:.6f},{te:.6f}\n")
with open(OUT_DIR / "pretraining_accuracy.csv", "w") as f:
    f.write("labels,meancolor,rawpixels,untrained,color,rotation,baseline\n")
    for i, m in enumerate(SIZES):
        f.write(f"{m},{acc['mean color'][i]:.4f},{acc['raw pixels'][i]:.4f},"
                f"{acc['untrained layers'][i]:.4f},{acc['color-pretrained layers'][i]:.4f},"
                f"{acc['rotation-pretrained layers'][i]:.4f},{acc_base:.4f}\n")

KM_X, KM_Y = cols * SIDE * 0.79 / 1000, rows * SIDE * 0.79 / 1000
split = KM_X / 2                                      # the line between the halves
vineyard = (labels[:rows * SIDE, :cols * SIDE] == 1)
fig, axes = plt.subplots(1, 2, figsize=(8, 2.6))
for ax, img, cmap, title in (
        (axes[0], photo[:rows * SIDE, :cols * SIDE], None,
         "Aerial photograph, 0.79 m per pixel"),
        (axes[1], vineyard, "gray", "Parcel register: vineyard parcels (white)")):
    ax.imshow(img, cmap=cmap, extent=[0, KM_X, 0, KM_Y])
    ax.axvline(split, color="black", linestyle="--", linewidth=1.2)
    ax.set_title(title, fontsize=11)
    ax.set_xlabel("easting (km)", fontsize=10)
    ax.set_ylabel("northing (km)", fontsize=10)
    ax.tick_params(labelsize=8)
    ax.text(split / 2, 0.06 * KM_Y, "western half:\ntraining set", fontsize=9,
            ha="center", va="bottom", color="black",
            bbox=dict(facecolor="white", edgecolor="none", pad=1.5))
    ax.text(1.5 * split, 0.06 * KM_Y, "eastern half:\ntest set", fontsize=9,
            ha="center", va="bottom", color="black",
            bbox=dict(facecolor="white", edgecolor="none", pad=1.5))
fig.tight_layout()
fig.savefig(OUT_DIR / "pretraining_scene.png", dpi=150)
plt.close(fig)

show = np.flatnonzero(test)[::288][:8]                # eight test patches, spread out
pred_show, _ = color_forward(params_color, X_blank[show])
GAP = 3
strip = np.ones((3 * SIDE + 2 * GAP, 8 * SIDE + 7 * GAP, 3), np.float32)
for i, idx in enumerate(show):
    col = i * (SIDE + GAP)
    blanked = X_blank[idx] + mean_color
    blanked[C0:C1, C0:C1, :] = 1.0                    # the blank shown white
    filled = blanked.copy()
    filled[C0:C1, C0:C1, :] = pred_show[i] + mean_color
    for r, img in enumerate((blanked, filled, patches[idx])):
        strip[r * (SIDE + GAP):r * (SIDE + GAP) + SIDE, col:col + SIDE] = np.clip(img, 0, 1)
plt.imsave(OUT_DIR / "pretraining_patches.png", strip.repeat(3, axis=0).repeat(3, axis=1))

fig, axes = plt.subplots(1, 3, figsize=(14, 3.8))
ax = axes[0]
steps = [s for s, _, _ in loss_log]
ax.plot(steps, [tr for _, tr, _ in loss_log], "-", color="black", label="training half")
ax.plot(steps, [te for _, _, te in loss_log], "--", color="0.5", label="test half")
ax.axhline(mse_ring, color="black", linestyle=":", label="mean color outside the blank (test)")
ax.set_yscale("log")
ax.set_xlabel("Adam iteration")
ax.set_ylabel("mean squared error of the center color")
ax.set_title("Color pretext task: predict the blanked center")
ax.legend(frameon=False, fontsize=8)
ax = axes[1]
ax.imshow(strip)
ax.set_xticks([])
ax.set_yticks([SIDE // 2 + r * (SIDE + GAP) for r in range(3)])
ax.set_yticklabels(["blanked", "predicted", "original"], fontsize=8)
ax.set_xlabel("eight test patches of 25 m")
ax.set_ylabel("row")
ax.set_title("Center color predicted from the surroundings")
ax = axes[2]
styles = {"mean color": ("^:", "0.5"), "raw pixels": ("s--", "0.5"),
          "untrained layers": ("o-", "0.5"), "color-pretrained layers": ("D-", "black"),
          "rotation-pretrained layers": ("v-", "black")}
for name, (style, color) in styles.items():
    ax.semilogx(SIZES, acc[name], style, color=color, markersize=4, label=name)
ax.axhline(acc_base, color="black", linestyle=":", linewidth=0.8, label="majority baseline")
ax.set_xlabel("number of labeled patches")
ax.set_ylabel("test accuracy (eastern half)")
ax.set_title("Linear classifier: vineyard vs. no vineyard")
ax.legend(frameon=False, fontsize=7, loc="lower right")
fig.tight_layout()
fig.savefig(OUT_DIR / "pretraining.png", dpi=150)
plt.close(fig)
check("[B-plot] the CSV files, the scene and the patch strip were written",
      all((OUT_DIR / f).exists() for f in
          ("pretraining_loss.csv", "pretraining_accuracy.csv",
           "pretraining_scene.png", "pretraining_patches.png")))

passed = sum(1 for _, ok in report if ok)
print(f"\n{passed}/{len(report)} checks pass")
  [ok] [B-plot] the CSV files, the scene and the patch strip were written

10/10 checks pass
Preview figure produced by pretraining.py
The preview figure the block B-plot writes when the script runs