Discuss your project
Artificial intelligence

Two tasks for one image: when do gradients conflict?

Train one network to recognize silhouettes and estimate their area. A step-by-step example shows how PCGrad projects opposing gradients, then compares that trade-off with two separate networks.

Parcours pédagogique : comprendre le cas, construire, mesurer puis vérifier.

A fictional vision station receives silhouettes of parts and must predict their geometric family and normalized visible area. We want to share an encoder while explicitly studying the effect of two objectives and their units.

For one image, show predicted class and area, then follow the dot product of two gradients and their projection in a small example. Compare a shared network, separate networks, and a PCGrad variant.

Download the complete project, revision r001

The tutorial path, from the concrete case to verification.

Two targets from the same image

PiecesDeuxTaches-v1 contains 300 silhouette families, 100 per rectangle, ellipse, and triangle class, with 20 views per family. A stratified seed-17 split places 70/15/15 families of every class in training/validation/test: 4,200/900/900 images. Views of one family share similar geometry. Translation, angle, scale, lighting, and noise vary; no neighbouring view crosses a partition.

Images are 64×64 uint8 matrices, divided by 255 before entering the CNN. Renderer parameters, class, and binary mask remain audit labels and are never features. Area is the exact number of mask pixels divided by 4096. It does not represent mm². The renderer rejects empty or edge-clipped silhouettes. Area ranges of all three classes overlap: size alone must not define class.

Population mean and standard deviation of area are calculated in FP64 on the 4,200 training images, saved, and frozen. The regression target is (area−training_mean)/training_standard_deviation. Evaluation applies the inverse transform. Calculating these statistics with test data would change the protocol.

Share parameters, keep two outputs

The original trunk has three 3×3 convolutions, stride 2 and padding 1, with 32/64/96 channels and ReLU. Spatial sizes go from 64 to 32, 16, then 8. Global average pooling gives 96 values. The class head is Linear(96,3), the area head Linear(96,1). There is no dropout, batch normalization, or area sigmoid.

The trunk has 74,208 parameters; heads have 291 and 97, or 74,596 unique parameters for the shared network. The two separate networks have 148,804 parameters together: one trunk and class head, then another trunk and area head. Copies have the same initial values as the shared branch but distinct storage. Their combined costs are measured. The same number of batches does not mean the same FLOPs.

Class loss is cross-entropy on three logits. Standardized area uses Huber delta=1: quadratic error near zero, linear growth beyond it. These objectives have different units and curvature. The shared control optimizes (L_class+L_area)/2. Each separate model receives its own loss divided by two to retain the scale convention.

What PCGrad projects

Autograd separately calculates g_class and g_area over the ordered list of trunk parameters. Flatten concatenates coordinates in that order; a missing gradient is an error for a declared shared parameter. The explicit allow_unused option permits zeros for other uses, but this practical exercise does not activate it to hide a disconnected head.

A negative dot product means the two local directions conflict. For each task i, start with a copy g_i_proj and visit other tasks j in saved random order. If g_i_proj·g_j_original<0, subtract [g_i_proj·g_j_original / ||g_j_original||²] g_j_original. References remain original gradients. An exactly zero norm is ignored; no division by zero or hidden epsilon is introduced.

The gradient given to the trunk is the average of the two projected vectors. Heads receive their own ordinary gradients divided by two. A head is not artificially projected against the other task's nonexistent gradient. The weighted control also calculates both gradients for diagnosis, then averages them; a test compares this operation with backward on the average of both losses.

The example g1=(1,0), g2=(−1,1) gives g1_proj=(0.5,0.5), g2_proj=(0,1), and average=(0.25,0.75). These vectors are constructed to explain the calculation. They do not prove every CNN batch conflicts. The course separately inspects a real initial batch and retains dot products/norms during training.

With two tasks, each task meets only one other reference: order has no effect here. The code accepts more tasks, and a three-task test checks RNG resume and use of original references. Successive projections across more than two tasks do not guarantee every final conflict disappears.

This variant is restricted to the shared trunk and aggregates by mean, aligned with the control. The historical TensorFlow repository aggregates by sum and its mixing line does not use the returned value; we follow the explicit order described by the paper's algorithm. The zero-norm case is also explicitly handled here. No equivalence with the complete TensorFlow trainer is claimed.

Train, inspect, and resume

The three main branches start from the same initial file. Each runs 30 epochs, batch 64, final batch of 40 images: 66 updates per epoch and 1,980 total. The two separate networks each make these 1,980 updates. AdamW uses lr=0.001, betas=(0.9;0.999), eps=1e−8, and weight_decay=0.0001, with no scheduler or gradient clipping. Validation follows losses without best-checkpoint selection; the final state is fixed in advance.

The CPU sampler, seed 117, produces the same orders for every branch. Projection RNG, seed 217, is saved even though two-task order is trivial. Every shared batch retains dot products, original/projected norms, and projection order. First batches of epochs 1, 15, and 30 retain full vectors, including head gradients. This probe subset is fixed before training; other batches retain scalars.

Each branch compares interruption after 12 epochs (792 updates), 30 continuous epochs, and resume for the final 18 (1,188 new updates). Weights, Adam, sampler, system RNG, projection RNG, and numerical histories must be exactly equal. Durations stay outside comparison. Local gzip-level-1 states are retained with hashes; final states are also saved in Safetensors and actually reloaded before inference. The program loads no third-party pickle.

Evaluate two units

Each branch predicts the same 900 images from 45 held-out families. Macro-F1 and confusion matrix cover the three classes. Area error is expressed in percentage points: fractional error 0.01 equals one point. MAE and RMSE are published, together with predictions outside [0,1]. They are not clipped to improve the table.

Each family has 20 images; its class accuracy and area MAE are reported. A paired bootstrap samples 45 families with replacement 1,000 times, seed 317. Intervals apply to accuracy and MAE differences, not to a renamed macro-F1. Bounds are sorted values at ranks 25 and 975 of 1,000. This describes variation between families in this corpus, not between training seeds.

There is no improvised composite score between F1 and area. A gain on one task can cost the other. Branch, validation, inference times, parameter count, and actually observed memory accompany quality. Resume and exercise costs are additional. Bootstrap does not turn this fictional corpus into an industrial evaluation.

Exercise: one loss one hundred times heavier

Two new weighted-x100 and pcgrad-x100 branches genuinely restart from initialization with the same data, normalization, order, and 1,980 updates. Only area loss is multiplied by 100, so is its head gradient. Code differentiates unweighted Huber, then explicitly multiplies gradients by the coefficient before projection and averaging. This mathematically equivalent form also fixes FP32 numerical order and avoids cancellation differences from rescaling before backpropagation. They have their own resume states and final weights. Main results are protected by fingerprints.

Compare norms from the first batch, projections, and both test tasks. PCGrad removes an opposing component; it does not automatically normalize task influence. AdamW may itself reduce some effects of uniform rescaling: results also depend on that optimizer. No PCGrad gain or ×100 degradation is imposed.

Limits

Synthetic silhouettes are regular and the two tasks can be compatible. A negative dot product is a local diagnostic; it is not enough to predict the test trade-off. One seed does not measure learning variability. Truly independent tasks or separate deployment constraints can justify two separate networks.

Prepare two coherent targets

Units and part-based partitions

Common error: normalization includes test data; calculate from training data and freeze its statistics.

Invariant: area in [0,1], disjoint families, and classes overlapping in size.

File src/pcgrad_multitache/data.py

"""Original raster silhouettes. Geometry is an audit label, never an input feature."""

import hashlib, json, math, random
from pathlib import Path
import numpy as np

CLASSES = ["rectangle", "ellipse", "triangle"]


def split_geometries():
    rng = random.Random(17)
    splits = {}
    for class_id in range(3):
        families = list(range(class_id, 300, 3))
        rng.shuffle(families)
        for i, family in enumerate(families):
            splits[family] = "train" if i < 70 else "validation" if i < 85 else "test"
    return splits


def geometry(family, view):
    if (
        type(family) is not int
        or type(view) is not int
        or not 0 <= family < 300
        or not 0 <= view < 20
    ):
        raise ValueError("Unknown family/view")
    group = family // 3
    kind = family % 3
    rng = np.random.default_rng(17000 + family * 20 + view)
    nominal = 0.06 + 0.10 * (group % 10) / 9
    aspect = 0.8 + 0.45 * ((group // 10) % 10) / 9
    scale = float(rng.uniform(0.90, 1.10))
    area = nominal * 4096 * scale**2
    factor = [4.0, math.pi, 2.0][kind]
    a = math.sqrt(area * aspect / factor)
    b = a / aspect
    return {
        "class_id": kind,
        "class_name": CLASSES[kind],
        "center_x": 31.5 + float(rng.uniform(-2, 2)),
        "center_y": 31.5 + float(rng.uniform(-2, 2)),
        "half_width": a,
        "half_height": b,
        "angle_radians": (group * 7 % 31) / 31 * 2 * math.pi
        + float(rng.uniform(-0.15, 0.15)),
        "nominal_area_fraction": nominal,
        "view_scale": scale,
        "background": int(rng.integers(12, 50)),
        "foreground": int(rng.integers(175, 241)),
        "lighting_x": float(rng.uniform(-12, 12)),
        "lighting_y": float(rng.uniform(-12, 12)),
        "noise_seed": 27000 + family * 20 + view,
    }


def render_part(parameters):
    yy, xx = np.mgrid[0:64, 0:64]
    dx = xx - parameters["center_x"]
    dy = yy - parameters["center_y"]
    cos = math.cos(parameters["angle_radians"])
    sin = math.sin(parameters["angle_radians"])
    x = cos * dx + sin * dy
    y = -sin * dx + cos * dy
    a = parameters["half_width"]
    b = parameters["half_height"]
    kind = parameters["class_id"]
    if kind == 0:
        mask = (np.abs(x) <= a) & (np.abs(y) <= b)
    elif kind == 1:
        mask = (x / a) ** 2 + (y / b) ** 2 <= 1
    elif kind == 2:
        mask = (y >= -b) & (y <= b) & (np.abs(x) <= a * (y + b) / (2 * b))
    else:
        raise ValueError("Unknown silhouette class")
    if (
        not mask.any()
        or mask[0].any()
        or mask[-1].any()
        or mask[:, 0].any()
        or mask[:, -1].any()
    ):
        raise ValueError(
            "Empty or border-clipped silhouette; do not silently change target"
        )
    noise = np.random.default_rng(parameters["noise_seed"]).integers(
        -6, 7, size=(64, 64)
    )
    illumination = (
        parameters["lighting_x"] * (xx - 31.5) / 32
        + parameters["lighting_y"] * (yy - 31.5) / 32
    )
    pixels = (
        np.rint(
            np.where(mask, parameters["foreground"], parameters["background"])
            + illumination
            + noise
        )
        .clip(0, 255)
        .astype(np.uint8)
    )
    return pixels, mask


def compute_area(mask):
    if (
        not isinstance(mask, np.ndarray)
        or mask.shape != (64, 64)
        or mask.dtype != np.bool_
    ):
        raise ValueError("Expected exact binary64x64mask")
    return int(mask.sum()), int(mask.sum()) / 4096


def decode_pixels(page):
    value = page["pixels_uint8_hex"]
    if not isinstance(value, str) or len(value) != 8192:
        raise ValueError("Expected4096uint8pixels")
    try:
        raw = bytes.fromhex(value)
    except ValueError as exc:
        raise ValueError("Invalid pixel encoding") from exc
    if len(raw) != 4096 or hashlib.sha256(raw).hexdigest() != page["image_sha256"]:
        raise ValueError("Image integrity mismatch")
    return np.frombuffer(raw, dtype=np.uint8).reshape(64, 64).copy()


def generate_pieces():
    splits = split_geometries()
    pages = []
    labels = []
    for family in range(300):
        for view in range(20):
            identifier = f"PIECE{family:03d}-V{view:02d}"
            parameters = geometry(family, view)
            pixels, mask = render_part(parameters)
            count, area = compute_area(mask)
            raw = pixels.tobytes()
            pages.append(
                {
                    "document_id": identifier,
                    "family": f"PIECE{family:03d}",
                    "split": splits[family],
                    "view": view,
                    "width": 64,
                    "height": 64,
                    "pixels_uint8_hex": raw.hex(),
                    "image_sha256": hashlib.sha256(raw).hexdigest(),
                }
            )
            labels.append(
                {
                    "document_id": identifier,
                    "class_id": family % 3,
                    "class_name": CLASSES[family % 3],
                    "mask_pixels": count,
                    "total_pixels": 4096,
                    "area_fraction": area,
                    "mask_bits_hex": np.packbits(mask.reshape(-1), bitorder="big")
                    .tobytes()
                    .hex(),
                    "renderer_parameters": parameters,
                }
            )
    return pages, labels


def audit_data(pages, labels):
    if (
        len(pages) != 6000
        or len(labels) != 6000
        or len({p["document_id"] for p in pages}) != 6000
        or len({p["image_sha256"] for p in pages}) != 6000
    ):
        raise ValueError("Expected6000uniqueimages")
    gold = {g["document_id"]: g for g in labels}
    families = {}
    counts = {s: [0, 0, 0] for s in ["train", "validation", "test"]}
    area_ranges = {k: [] for k in CLASSES}
    train = []
    for page in pages:
        g = gold[page["document_id"]]
        decode_pixels(page)
        mask = (
            np.unpackbits(
                np.frombuffer(bytes.fromhex(g["mask_bits_hex"]), dtype=np.uint8),
                bitorder="big",
            )
            .reshape(64, 64)
            .astype(bool)
        )
        n, area = compute_area(mask)
        if n != g["mask_pixels"] or area != g["area_fraction"] or not 0 < area < 1:
            raise ValueError("Actual binarymaskarea differs")
        if {
            "class_id",
            "area_fraction",
            "renderer_parameters",
            "mask_bits_hex",
        } & page.keys():
            raise ValueError("Targets must not be input features")
        families.setdefault(page["family"], []).append(page)
        counts[page["split"]][g["class_id"]] += 1
        area_ranges[g["class_name"]].append(area)
        if page["split"] == "train":
            train.append(area)
    if (
        len(families) != 300
        or any(
            len(rows) != 20 or len({r["split"] for r in rows}) != 1
            for rows in families.values()
        )
        or counts != {"train": [1400] * 3, "validation": [300] * 3, "test": [300] * 3}
    ):
        raise ValueError("Whole-family balanced partitions differ")
    ranges = {
        k: {"minimum": min(values), "maximum": max(values)}
        for k, values in area_ranges.items()
    }
    overlap = [
        max(r["minimum"] for r in ranges.values()),
        min(r["maximum"] for r in ranges.values()),
    ]
    if overlap[0] >= overlap[1]:
        raise ValueError("Area alone separates the classes: fix corpus before training")
    values = np.array(train, dtype=np.float64)
    normalization = {
        "population": "train",
        "examples": 4200,
        "mean": float(values.mean()),
        "std": float(values.std(ddof=0)),
        "ddof": 0,
        "unit": "fraction of4096binarypixels",
    }
    if normalization["std"] <= 0:
        raise ValueError("Degenerate area normalization")
    return {
        "dataset": "PiecesDeuxTaches-v1",
        "images": 6000,
        "families": 300,
        "class_counts_by_split": counts,
        "normalization": normalization,
        "area_ranges_by_class": ranges,
        "common_area_interval": overlap,
        "masks_are_supervision_not_model_inputs": True,
        "real_physical_area_claim": False,
    }


def prepare(config):
    from .config import save_json, sha

    root = Path("data/generated")
    if config["seed"] != 17 or (root / "manifest.json").exists():
        raise ValueError("Expectedseed17andfreshdataoutput")
    pages, labels = generate_pieces()
    audit = audit_data(pages, labels)
    gold = {g["document_id"]: g for g in labels}
    save_json(root / "input/pages.json", pages)
    save_json(root / "labels/gold.json", labels)
    for split in ["train", "validation"]:
        selected = [p for p in pages if p["split"] == split]
        save_json(root / f"{split}/pages.json", selected)
        save_json(
            root / f"labels/{split}.json", [gold[p["document_id"]] for p in selected]
        )
    save_json("outputs/data-audit.json", audit)
    save_json("outputs/learning/normalization.json", audit["normalization"])
    save_json(
        root / "manifest.json",
        {
            "dataset": audit["dataset"],
            "images": 6000,
            "files": {
                str(f.relative_to(root)): sha(f) for f in root.rglob("*") if f.is_file()
            },
        },
    )
    return audit


def load_learning(split):
    from .config import read_json
    import torch

    if split not in ["train", "validation"]:
        raise ValueError("Onlytrain/validationforlearning")
    for forbidden in [
        "data/generated/input",
        "data/generated/labels/gold.json",
        "outputs/inference",
    ]:
        if Path(forbidden).exists():
            raise ValueError("Learning scope exposesheldoutdata:" + forbidden)
    pages = read_json(f"data/generated/{split}/pages.json")
    gold = {
        g["document_id"]: g for g in read_json(f"data/generated/labels/{split}.json")
    }
    norm = read_json("outputs/learning/normalization.json")
    x = (
        torch.from_numpy(np.stack([decode_pixels(p) for p in pages])[:, None]).float()
        / 255.0
    )
    classes = torch.tensor(
        [gold[p["document_id"]]["class_id"] for p in pages], dtype=torch.long
    )
    area = torch.tensor(
        [
            (gold[p["document_id"]]["area_fraction"] - norm["mean"]) / norm["std"]
            for p in pages
        ],
        dtype=torch.float32,
    )
    return pages, x, classes, area, norm

Train the shared baseline

One trunk, two heads

Common error: a head is detached from the trunk; test both gradients separately.

Invariant: two finite outputs and identifiable shared parameters.

File src/pcgrad_multitache/model.py

import copy, hashlib, json
import torch
from torch import nn


class SharedModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.trunk = nn.Sequential(
            nn.Conv2d(1, 32, 3, stride=2, padding=1),
            nn.ReLU(),
            nn.Conv2d(32, 64, 3, stride=2, padding=1),
            nn.ReLU(),
            nn.Conv2d(64, 96, 3, stride=2, padding=1),
            nn.ReLU(),
            nn.AdaptiveAvgPool2d(1),
            nn.Flatten(),
        )
        self.class_head = nn.Linear(96, 3)
        self.area_head = nn.Linear(96, 1)

    def encode_part(self, x):
        if x.ndim != 4 or x.shape[1:] != (1, 64, 64) or x.dtype != torch.float32:
            raise ValueError("Only grayscale FP32 batch x1x64x64 inputs")
        return self.trunk(x)

    def forward(self, x):
        encoded = self.encode_part(x)
        return self.class_head(encoded), self.area_head(encoded).squeeze(-1)


class SeparateModels(nn.Module):
    def __init__(self, initial):
        super().__init__()
        self.class_trunk = copy.deepcopy(initial.trunk)
        self.area_trunk = copy.deepcopy(initial.trunk)
        self.class_head = copy.deepcopy(initial.class_head)
        self.area_head = copy.deepcopy(initial.area_head)

    def forward(self, x):
        return self.class_head(self.class_trunk(x)), self.area_head(
            self.area_trunk(x)
        ).squeeze(-1)


def parameter_manifest(model):
    return [
        {
            "name": n,
            "shape": list(p.shape),
            "numel": p.numel(),
            "dtype": str(p.dtype),
            "sha256": hashlib.sha256(
                p.detach().cpu().contiguous().numpy().tobytes()
            ).hexdigest(),
        }
        for n, p in model.named_parameters()
    ]


def identity(model):
    return hashlib.sha256(
        json.dumps(
            parameter_manifest(model), sort_keys=True, separators=(",", ":")
        ).encode()
    ).hexdigest()


def losses(logits, predicted_area, classes, standard_area, area_scale=1.0):
    return torch.nn.functional.cross_entropy(
        logits, classes
    ), area_scale * torch.nn.functional.huber_loss(
        predicted_area, standard_area, delta=1.0
    )

Code PCGrad without an opaque helper

Projection onto the shared subspace

Common error: reusing modified gradients as references; copy the originals.

Invariant: exact vector fixture and no NaN for a zero gradient.

File src/pcgrad_multitache/pcgrad.py

"""Original gradient projection, restricted explicitly to shared parameters."""

import random
import torch


def flatten_gradients(gradients, parameters, allow_unused=False):
    parameters = list(parameters)
    gradients = list(gradients)
    if (
        not parameters
        or len(parameters) != len(gradients)
        or len({id(p) for p in parameters}) != len(parameters)
    ):
        raise ValueError("Stable nonempty unique parameter list required")
    flat = []
    for gradient, parameter in zip(gradients, parameters):
        if gradient is None:
            if not allow_unused:
                raise ValueError(
                    "A task is disconnected from a declared shared parameter"
                )
            gradient = torch.zeros_like(parameter)
        if (
            gradient.shape != parameter.shape
            or gradient.dtype != parameter.dtype
            or gradient.device != parameter.device
            or not torch.isfinite(gradient).all()
        ):
            raise ValueError("Gradient shape/dtype/device/finite contract differs")
        flat.append(gradient.detach().reshape(-1))
    return torch.cat(flat)


def project_conflicts(gradients, generator, orders=None):
    if (
        not isinstance(gradients, torch.Tensor)
        or gradients.ndim != 2
        or gradients.shape[0] < 2
        or gradients.shape[1] < 1
        or gradients.dtype not in [torch.float32, torch.float64]
        or not torch.isfinite(gradients).all()
    ):
        raise ValueError("Expected finite task-by-coordinate FP32/FP64 gradients")
    if not isinstance(generator, random.Random):
        raise ValueError("Use an explicit checkpointed projection RNG")
    original = gradients.detach().clone()
    projected = original.clone()
    tasks = len(original)
    if orders is None:
        orders = []
        for i in range(tasks):
            others = [j for j in range(tasks) if j != i]
            generator.shuffle(others)
            orders.append(others)
    if len(orders) != tasks or any(
        sorted(order) != [j for j in range(tasks) if j != i]
        for i, order in enumerate(orders)
    ):
        raise ValueError("Each order must visit every other original task exactly once")
    trace = []
    for i, order in enumerate(orders):
        for j in order:
            reference = original[j]
            norm2 = torch.dot(reference, reference)
            dot = torch.dot(projected[i], reference)
            if not torch.isfinite(norm2) or not torch.isfinite(dot):
                raise ValueError("Gradient products overflow; no silent normalization")
            changed = bool(norm2 > 0 and dot < 0)
            coefficient = dot / norm2 if changed else torch.zeros_like(dot)
            if changed:
                projected[i] -= coefficient * reference
            trace.append(
                {
                    "task": i,
                    "reference": j,
                    "dot_before": float(dot),
                    "reference_norm_squared": float(norm2),
                    "coefficient": float(coefficient),
                    "projected": changed,
                    "zero_reference_skipped": bool(norm2 == 0),
                }
            )
    if not torch.isfinite(projected).all():
        raise ValueError("Nonfinite projection")
    return (
        projected,
        projected.mean(dim=0),
        {
            "orders": orders,
            "trace": trace,
            "reference_gradients_unchanged": torch.equal(original, gradients.detach()),
            "aggregation": "mean",
            "zero_norm_rule": "skip-exact-zero",
        },
    )


def assign_gradients(parameters, flat):
    parameters = list(parameters)
    if (
        not parameters
        or len({id(p) for p in parameters}) != len(parameters)
        or flat.ndim != 1
        or sum(p.numel() for p in parameters) != flat.numel()
        or not torch.isfinite(flat).all()
    ):
        raise ValueError("Unflatten population/shape/finite mismatch")
    offset = 0
    for parameter in parameters:
        if parameter.dtype != flat.dtype or parameter.device != flat.device:
            raise ValueError("Assigned gradient dtype/device mismatch")
        parameter.grad = (
            flat[offset : offset + parameter.numel()]
            .reshape_as(parameter)
            .detach()
            .clone()
        )
        offset += parameter.numel()


def task_gradients(model, class_loss, area_loss):
    shared = list(model.trunk.parameters())
    class_head = list(model.class_head.parameters())
    area_head = list(model.area_head.parameters())
    a = torch.autograd.grad(
        class_loss, shared + class_head, retain_graph=True, allow_unused=False
    )
    b = torch.autograd.grad(
        area_loss, shared + area_head, retain_graph=False, allow_unused=False
    )
    original = torch.stack(
        [
            flatten_gradients(a[: len(shared)], shared),
            flatten_gradients(b[: len(shared)], shared),
        ]
    )
    return shared, class_head, area_head, original, a[len(shared) :], b[len(shared) :]


def set_multitask_gradients(
    model, class_loss, area_loss, method, generator, area_scale=1.0
):
    if method not in ["weighted", "pcgrad"]:
        raise ValueError("Unknown shared update rule")
    if not isinstance(area_scale, (int, float)) or not area_scale > 0:
        raise ValueError("Use a finite positive area scale")
    shared, class_head, area_head, original, head_class, head_area = task_gradients(
        model, class_loss, area_loss
    )
    # Differentiate the unscaled Huber loss first, then apply the declared
    # objective scale explicitly. This avoids scale-dependent floating-point
    # rounding inside autograd and makes the x100 exercise auditable.
    original[1].mul_(area_scale)
    if method == "pcgrad":
        projected, assigned, trace = project_conflicts(original, generator)
    else:
        projected = original.clone()
        assigned = original.mean(0)
        trace = {
            "orders": [],
            "trace": [],
            "reference_gradients_unchanged": True,
            "aggregation": "mean",
            "zero_norm_rule": "not-applicable",
        }
    assign_gradients(shared, assigned)
    # Disjoint heads receive ordinary own-task gradients scaled by the same 1/2.
    a = flatten_gradients(head_class, class_head) * 0.5
    b = flatten_gradients(head_area, area_head) * (0.5 * area_scale)
    assign_gradients(class_head, a)
    assign_gradients(area_head, b)
    return {
        "original": original,
        "projected": projected,
        "assigned": assigned,
        "class_head_assigned": a,
        "area_head_assigned": b,
        "trace": trace,
    }

Train and resume branches

Constant-batch comparison

Common error: graph accumulation retains the entire epoch; release after every update.

Invariant: parameters change; resume restores projection permutation.

File src/pcgrad_multitache/train.py

"""Explicit fixed-budget supervised updates and exact complete-state continuation."""

import copy, json, random, time
from pathlib import Path
import torch
from safetensors.torch import save_file, load_file
from .config import seed_all, save_json, read_json, sha, rng_state, restore_rng
from .model import SharedModel, SeparateModels, identity, parameter_manifest, losses
from .pcgrad import set_multitask_gradients
from .data import load_learning
from .state import save_resume_state, load_resume_state, equal

BRANCHES = {
    "separate": ("separate", 1.0),
    "weighted": ("weighted", 1.0),
    "pcgrad": ("pcgrad", 1.0),
    "weighted-x100": ("weighted", 100.0),
    "pcgrad-x100": ("pcgrad", 100.0),
}


def create_initial():
    seed_all(17)
    model = SharedModel()
    path = Path("assets/checkpoints/initial.safetensors")
    if path.exists():
        raise ValueError("Preserve existing initialization")
    path.parent.mkdir(parents=True, exist_ok=True)
    save_file(
        {k: v.detach().cpu().contiguous() for k, v in model.state_dict().items()},
        str(path),
    )
    report = {
        "status": "CREATED_RANDOM_INITIALIZATION",
        "seed": 17,
        "shared_parameters": sum(p.numel() for p in model.parameters()),
        "trunk_parameters": sum(p.numel() for p in model.trunk.parameters()),
        "class_head_parameters": sum(p.numel() for p in model.class_head.parameters()),
        "area_head_parameters": sum(p.numel() for p in model.area_head.parameters()),
        "separate_combined_parameters": sum(
            p.numel() for p in SeparateModels(model).parameters()
        ),
        "initial_model_identity": identity(model),
        "initial_file_sha256": sha(path),
        "parameters": parameter_manifest(model),
        "no_external_weights": True,
    }
    save_json("outputs/learning/initialization.json", report)
    return {k: v for k, v in report.items() if k != "parameters"}


def initialize_branch(branch):
    if branch not in BRANCHES:
        raise ValueError("Unknown branch")
    seed_all(17)
    initial = SharedModel()
    initial.load_state_dict(
        load_file("assets/checkpoints/initial.safetensors"), strict=True
    )
    if (
        identity(initial)
        != read_json("outputs/learning/initialization.json")["initial_model_identity"]
    ):
        raise ValueError("Initial common state differs")
    return SeparateModels(initial) if branch == "separate" else initial


@torch.no_grad()
def validation(model, x, classes, area):
    sums = [0.0, 0.0]
    correct = 0
    rows = []
    for start in range(0, len(x), 64):
        logits, pred = model(x[start : start + 64].cuda())
        c = classes[start : start + 64].cuda()
        a = area[start : start + 64].cuda()
        ce, hub = losses(logits, pred, c, a)
        n = len(c)
        sums[0] += float(ce) * n
        sums[1] += float(hub) * n
        correct += int((logits.argmax(-1) == c).sum())
        rows.extend(
            [
                {"logits": z, "predicted_standard_area": v}
                for z, v in zip(logits.cpu().tolist(), pred.cpu().tolist())
            ]
        )
    return {
        "examples": len(x),
        "ce_mean": sums[0] / len(x),
        "unscaled_huber_mean": sums[1] / len(x),
        "correct_class": correct,
        "rows": rows,
        "used_for_selection": False,
    }


def scalar_diagnostics(trace):
    original = trace["original"]
    projected = trace["projected"]
    a, b = original
    norms = [float(torch.linalg.vector_norm(g)) for g in original]
    dot = float(torch.dot(a, b))
    den = norms[0] * norms[1]
    return {
        "original_dot": dot,
        "original_norms": norms,
        "original_cosine": dot / den if den else None,
        "conflict": dot < 0,
        "projected_norms": [float(torch.linalg.vector_norm(g)) for g in projected],
        "assigned_norm": float(torch.linalg.vector_norm(trace["assigned"])),
        "class_head_assigned_norm": float(
            torch.linalg.vector_norm(trace["class_head_assigned"])
        ),
        "area_head_assigned_norm": float(
            torch.linalg.vector_norm(trace["area_head_assigned"])
        ),
        "projection": trace["trace"],
    }


def train(config, branch, mode):
    if branch not in BRANCHES or mode not in ["interrupt", "continuous", "resume"]:
        raise ValueError("Unknown branch/mode")
    if (
        config["seed"] != 17
        or config["epochs"] != 30
        or config["batch_size"] != 64
        or config["diagnostic_epochs"] != [1, 15, 30]
    ):
        raise ValueError("Frozen training budget changed")
    pages, x, classes, area, norm = load_learning("train")
    val_pages, vx, vc, va, vnorm = load_learning("validation")
    if len(pages) != 4200 or len(val_pages) != 900 or norm != vnorm:
        raise ValueError("Fixed partitions/normalization differ")
    model = initialize_branch(branch).cuda()
    model.eval()
    method, area_scale = BRANCHES[branch]
    initial = identity(model)
    initial_parameters = parameter_manifest(model)
    optimizer = torch.optim.AdamW(
        model.parameters(), lr=0.001, weight_decay=0.0001, betas=(0.9, 0.999), eps=1e-8
    )
    sampler = torch.Generator().manual_seed(117)
    projection_rng = random.Random(217)
    identity_data = {
        "config": config,
        "branch": branch,
        "method": method,
        "area_scale": area_scale,
        "initial_model_identity": initial,
        "common_initial_sha256": sha("assets/checkpoints/initial.safetensors"),
        "normalization_sha256": sha("outputs/learning/normalization.json"),
        "data_sha256": {
            name: sha("data/generated/" + name)
            for name in [
                "train/pages.json",
                "validation/pages.json",
                "labels/train.json",
                "labels/validation.json",
            ]
        },
        "code_sha256": {
            name: sha("src/pcgrad_multitache/" + name)
            for name in ["data.py", "model.py", "pcgrad.py", "train.py", "state.py"]
        },
    }
    root = Path("assets/checkpoints")
    destination = root / f"{branch}-{mode}.pt.gz"
    if destination.exists():
        raise ValueError("Preserve existing checkpoint")
    first_epoch = updates = 0
    history = []
    validations = []
    if mode == "resume":
        state = load_resume_state(root / f"{branch}-interrupt.pt.gz")
        if state["identity"] != identity_data:
            raise ValueError("Continuation identity changed")
        model.load_state_dict(state["model"], strict=True)
        optimizer.load_state_dict(state["optimizer"])
        sampler.set_state(state["sampler"])
        projection_rng.setstate(state["projection_rng"])
        restore_rng(state["rng"])
        first_epoch = state["epoch"]
        updates = state["updates"]
        history = state["history"]
        validations = state["validation_history"]
        if first_epoch != 12 or updates != 792 or state["scheduler"] is not None:
            raise ValueError("Expected exact epoch12 boundary")
    stop = 12 if mode == "interrupt" else 30
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()
    started = time.perf_counter()
    training_seconds = validation_seconds = 0.0
    for epoch in range(first_epoch, stop):
        order = torch.randperm(4200, generator=sampler).tolist()
        torch.cuda.synchronize()
        tick = time.perf_counter()
        for offset in range(0, 4200, 64):
            indices = order[offset : offset + 64]
            batch = x[indices].cuda()
            c = classes[indices].cuda()
            a = area[indices].cuda()
            optimizer.zero_grad(set_to_none=True)
            logits, predicted = model(batch)
            ce, huber = losses(logits, predicted, c, a, 1.0)
            ce_value = float(ce.detach())
            huber_value = float(huber.detach())
            diagnostic = None
            probe = None
            if method == "separate":
                ((ce + area_scale * huber) / 2).backward()
            else:
                trace = set_multitask_gradients(
                    model, ce, huber, method, projection_rng, area_scale=area_scale
                )
                diagnostic = scalar_diagnostics(trace)
                if epoch + 1 in [1, 15, 30] and offset == 0:
                    probe = {
                        k: trace[k].cpu().tolist()
                        for k in [
                            "original",
                            "projected",
                            "assigned",
                            "class_head_assigned",
                            "area_head_assigned",
                        ]
                    }
            if any(
                p.grad is None or not torch.isfinite(p.grad).all()
                for p in model.parameters()
            ):
                raise ValueError("Missing or nonfinite actual gradient")
            norm_grad = float(
                torch.sqrt(sum(p.grad.square().sum() for p in model.parameters()))
            )
            if not torch.isfinite(torch.tensor(norm_grad)):
                raise ValueError("Nonfinite update norm")
            optimizer.step()
            updates += 1
            history.append(
                {
                    "epoch": epoch + 1,
                    "update": updates,
                    "document_ids": [pages[i]["document_id"] for i in indices],
                    "examples": len(indices),
                    "class_ce": ce_value,
                    "unscaled_area_huber": huber_value,
                    "area_scale": area_scale,
                    "weighted_objective": (ce_value + area_scale * huber_value) / 2,
                    "parameter_gradient_norm": norm_grad,
                    "diagnostic": diagnostic,
                    "full_vector_probe": probe,
                }
            )
        torch.cuda.synchronize()
        training_seconds += time.perf_counter() - tick
        tick = time.perf_counter()
        validations.append({"epoch": epoch + 1, **validation(model, vx, vc, va)})
        torch.cuda.synchronize()
        validation_seconds += time.perf_counter() - tick
    torch.cuda.synchronize()
    seconds = time.perf_counter() - started
    final = identity(model)
    final_parameters = parameter_manifest(model)
    if initial == final or not any(h["parameter_gradient_norm"] > 0 for h in history):
        raise ValueError("No actual learning")
    state = {
        "identity": identity_data,
        "model": {n: t.detach().cpu().clone() for n, t in model.state_dict().items()},
        "optimizer": optimizer.state_dict(),
        "sampler": sampler.get_state(),
        "projection_rng": projection_rng.getstate(),
        "rng": rng_state(),
        "epoch": stop,
        "updates": updates,
        "history": history,
        "validation_history": validations,
        "scheduler": None,
    }
    storage = save_resume_state(destination, state)
    report = {
        "branch": branch,
        "mode": mode,
        "identity": identity_data,
        "epochs": stop,
        "updates": updates,
        "initial_model_identity": initial,
        "final_model_identity": final,
        "initial_parameters": initial_parameters,
        "final_parameters": final_parameters,
        "history": history,
        "validation_history": validations,
        "actual_training_interval_epochs": stop - first_epoch,
        "seconds": seconds,
        "training_seconds": training_seconds,
        "validation_seconds": validation_seconds,
        "peak_allocated_bytes": torch.cuda.max_memory_allocated(),
        "peak_reserved_bytes": torch.cuda.max_memory_reserved(),
        "checkpoint_storage": storage,
        "checkpoint_sha256": sha(destination),
        "heldout_inputs_visible": False,
        "separate_cost_includes_two_networks": method == "separate",
    }
    save_json(f"outputs/learning/{branch}/{mode}.json", report)
    if mode == "resume":
        safe = root / f"{branch}-resume.safetensors"
        if safe.exists():
            raise ValueError("Preserve existing learned model")
        save_file(
            {n: t.detach().cpu().contiguous() for n, t in model.state_dict().items()},
            str(safe),
        )
        save_json(
            safe.with_suffix(".json"),
            {
                "identity": identity_data,
                "sha256": sha(safe),
                "model_identity": final,
                "parameters": final_parameters,
            },
        )
    return {
        "branch": branch,
        "mode": mode,
        "epochs": stop,
        "updates": updates,
        "seconds": seconds,
    }


def verify_resume(branch):
    if branch not in BRANCHES:
        raise ValueError("Unknown branch")
    a = Path("assets/checkpoints") / f"{branch}-continuous.pt.gz"
    b = Path("assets/checkpoints") / f"{branch}-resume.pt.gz"
    continuous = load_resume_state(a)
    resumed = load_resume_state(b)
    if not equal(continuous, resumed):
        raise ValueError("Model/Adam/RNG/sampler/projection continuation differs")
    report = {
        "status": "PASS",
        "branch": branch,
        "all_state_exact": True,
        "updates": resumed["updates"],
        "continuous_sha256": sha(a),
        "resume_sha256": sha(b),
        "projection_rng_included": True,
    }
    save_json(f"outputs/learning/{branch}/resume-check.json", report)
    return report

Interpret both tasks

No improvised composite score

Common error: compare standardized area with pixels; apply inverse normalization.

Invariant: every result corresponds to the same 900 planned cases.

File src/pcgrad_multitache/evaluate.py

import json, math, random, time
from pathlib import Path
import numpy as np
import torch
from safetensors.torch import load_file
from .config import read_json, save_json, sha, seed_all
from .data import decode_pixels
from .model import SharedModel, SeparateModels, identity


def class_metrics(actual, predicted):
    if (
        not actual
        or len(actual) != len(predicted)
        or any(type(x) is not int or not 0 <= x < 3 for x in actual + predicted)
    ):
        raise ValueError("Aligned nonempty threeclass predictions required")
    matrix = [[0] * 3 for _ in range(3)]
    for a, p in zip(actual, predicted):
        matrix[a][p] += 1
    f1 = []
    for c in range(3):
        tp = matrix[c][c]
        fp = sum(matrix[r][c] for r in range(3) if r != c)
        fn = sum(matrix[c][r] for r in range(3) if r != c)
        f1.append(2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else 0.0)
    return {
        "cases": len(actual),
        "confusion_matrix": matrix,
        "class_support": [sum(row) for row in matrix],
        "per_class_f1": f1,
        "macro_f1": sum(f1) / 3,
        "correct": sum(matrix[c][c] for c in range(3)),
        "accuracy": sum(matrix[c][c] for c in range(3)) / len(actual),
    }


def area_metrics(actual, predicted):
    if (
        not actual
        or len(actual) != len(predicted)
        or not all(math.isfinite(v) for v in actual + predicted)
        or any(not 0 <= v <= 1 for v in actual)
    ):
        raise ValueError("Finite aligned fractionarea targets required")
    errors = [p - a for a, p in zip(actual, predicted)]
    return {
        "cases": len(actual),
        "mae_percentage_points": 100 * sum(abs(e) for e in errors) / len(errors),
        "rmse_percentage_points": 100
        * math.sqrt(sum(e * e for e in errors) / len(errors)),
        "prediction_outside_unit_interval": sum(p < 0 or p > 1 for p in predicted),
        "prediction_clipping": False,
    }


@torch.no_grad()
def predict(config, branch):
    if branch not in ["separate", "weighted", "pcgrad", "weighted-x100", "pcgrad-x100"]:
        raise ValueError("Unknown learned branch")
    if Path("data/generated/labels").exists() or Path("outputs/learning").exists():
        raise ValueError("Inference must not mount labels or learning outputs")
    seed_all(17)
    pages = read_json("data/generated/input/pages.json")
    test = [p for p in pages if p["split"] == "test"]
    warm = next(p for p in pages if p["split"] == "train")
    if len(test) != 900:
        raise ValueError("Exactly900testimages required")
    path = Path("assets/checkpoints") / f"{branch}-resume.safetensors"
    metadata = read_json(path.with_suffix(".json"))
    if sha(path) != metadata["sha256"]:
        raise ValueError("Learned state SHA mismatch")
    started = time.perf_counter()
    model = SharedModel()
    model = SeparateModels(model) if branch == "separate" else model
    model.load_state_dict(load_file(str(path)), strict=True)
    if identity(model) != metadata["model_identity"]:
        raise ValueError("Actual Safetensors reload differs")
    model = model.cuda().eval().requires_grad_(False)
    torch.cuda.synchronize()
    load_seconds = time.perf_counter() - started
    norm = read_json("assets/checkpoints/normalization.json")
    warm_x = torch.from_numpy(decode_pixels(warm)[None, None]).float().cuda() / 255.0
    torch.cuda.synchronize()
    started = time.perf_counter()
    warm_logits, warm_area = model(warm_x)
    torch.cuda.synchronize()
    warm_seconds = time.perf_counter() - started
    rows = []
    batch_times = []
    torch.cuda.reset_peak_memory_stats()
    started = time.perf_counter()
    for start in range(0, 900, 64):
        current = test[start : start + 64]
        x = (
            torch.from_numpy(np.stack([decode_pixels(p) for p in current])[:, None])
            .float()
            .cuda()
            / 255.0
        )
        torch.cuda.synchronize()
        tick = time.perf_counter()
        logits, area = model(x)
        torch.cuda.synchronize()
        batch_times.append(
            {
                "start": start,
                "examples": len(current),
                "forward_seconds": time.perf_counter() - tick,
            }
        )
        raw_logits = logits.cpu().tolist()
        raw_area = area.cpu().tolist()
        for page, z, a in zip(current, raw_logits, raw_area):
            predicted = int(max(range(3), key=z.__getitem__))
            rows.append(
                {
                    "document_id": page["document_id"],
                    "family": page["family"],
                    "image_sha256": page["image_sha256"],
                    "class_logits": z,
                    "predicted_class": predicted,
                    "predicted_standard_area": a,
                    "predicted_area_fraction": a * norm["std"] + norm["mean"],
                }
            )
    torch.cuda.synchronize()
    seconds = time.perf_counter() - started
    if identity(model) != metadata["model_identity"] or not all(
        math.isfinite(v)
        for row in rows
        for v in row["class_logits"] + [row["predicted_area_fraction"]]
    ):
        raise ValueError("Inference changed parameters or returned nonfinite outputs")
    report = {
        "branch": branch,
        "rows": rows,
        "cases": 900,
        "checkpoint_sha256": sha(path),
        "model_identity": metadata["model_identity"],
        "normalization_sha256": sha("assets/checkpoints/normalization.json"),
        "normalization": norm,
        "parameters": sum(p.numel() for p in model.parameters()),
        "logical_parameter_bytes": sum(
            p.numel() * p.element_size() for p in model.parameters()
        ),
        "served_weight_file_bytes": path.stat().st_size,
        "load_seconds": load_seconds,
        "warmup": {
            "document_id": warm["document_id"],
            "seconds": warm_seconds,
            "class_logits": warm_logits.cpu().tolist(),
            "standard_area": warm_area.cpu().tolist(),
            "outside_test": True,
        },
        "inference_seconds": seconds,
        "forward_batches": batch_times,
        "labels_visible": False,
        "prediction_clipping": False,
        "batch_size": 64,
        "peak_allocated_bytes": torch.cuda.max_memory_allocated(),
        "peak_reserved_bytes": torch.cuda.max_memory_reserved(),
        "two_networks_combined": branch == "separate",
    }
    save_json(f"outputs/inference/{branch}.json", report)
    return {
        k: v
        for k, v in report.items()
        if k not in ["rows", "warmup", "forward_batches"]
    }


def family_metrics(rows):
    result = {}
    for family in sorted({r["family"] for r in rows}):
        chosen = [r for r in rows if r["family"] == family]
        result[family] = {
            "cases": len(chosen),
            "class_accuracy": sum(
                r["predicted_class"] == r["target_class"] for r in chosen
            )
            / len(chosen),
            "area_mae_percentage_points": 100
            * sum(
                abs(r["predicted_area_fraction"] - r["target_area_fraction"])
                for r in chosen
            )
            / len(chosen),
        }
    return result


def paired_family_bootstrap(a, b):
    if (
        set(a) != set(b)
        or len(a) != 45
        or any(a[k]["cases"] != 20 or b[k]["cases"] != 20 for k in a)
    ):
        raise ValueError("Same45familiesof20images required")
    families = sorted(a)
    rng = random.Random(317)
    draws = {k: [] for k in ["class_accuracy", "area_mae_percentage_points"]}
    for _ in range(1000):
        sample = [families[rng.randrange(45)] for _ in range(45)]
        for k in draws:
            draws[k].append(sum(b[f][k] - a[f][k] for f in sample) / 45)
    return {
        "draws": 1000,
        "seed": 317,
        "resampling_unit": "wholefamily",
        "families": 45,
        "difference": "second minus first",
        "empirical_interval_order_statistics_1based": [25, 975],
        "intervals": {
            k: {
                "observed": sum(b[f][k] - a[f][k] for f in families) / 45,
                "lower": sorted(v)[24],
                "upper": sorted(v)[974],
            }
            for k, v in draws.items()
        },
        "not_training_seed_variability": True,
        "accuracy_interval_not_macro_f1_interval": True,
    }


def evaluate(config, exercise=False):
    gold = {r["document_id"]: r for r in read_json("data/generated/labels/gold.json")}
    branches = (
        ["weighted-x100", "pcgrad-x100"]
        if exercise
        else ["separate", "weighted", "pcgrad"]
    )
    policies = {}
    for branch in branches:
        raw = read_json(f"outputs/inference/{branch}.json")
        rows = [
            {
                **r,
                "target_class": gold[r["document_id"]]["class_id"],
                "target_area_fraction": gold[r["document_id"]]["area_fraction"],
            }
            for r in raw["rows"]
        ]
        classes = class_metrics(
            [r["target_class"] for r in rows], [r["predicted_class"] for r in rows]
        )
        areas = area_metrics(
            [r["target_area_fraction"] for r in rows],
            [r["predicted_area_fraction"] for r in rows],
        )
        policies[branch] = {
            "rows": rows,
            "class_metrics": classes,
            "area_metrics": areas,
            "families": family_metrics(rows),
            "raw_sha256": sha(f"outputs/inference/{branch}.json"),
            "costs": {
                k: raw[k]
                for k in [
                    "parameters",
                    "logical_parameter_bytes",
                    "served_weight_file_bytes",
                    "load_seconds",
                    "inference_seconds",
                    "forward_batches",
                    "peak_allocated_bytes",
                    "peak_reserved_bytes",
                    "two_networks_combined",
                ]
            },
        }
    paired = (
        {}
        if exercise
        else {
            "weighted_to_pcgrad": paired_family_bootstrap(
                policies["weighted"]["families"], policies["pcgrad"]["families"]
            ),
            "separate_to_weighted": paired_family_bootstrap(
                policies["separate"]["families"], policies["weighted"]["families"]
            ),
        }
    )
    result = {
        "policies": policies,
        "paired_family_bootstrap": paired,
        "scope": "Syntheticsilhouettes;3classmacroF1andareaMAEpercentagepoints keptseparate. Test900images45families; no clippingoutofrangearea andnoqualitygain imposed.",
    }
    save_json(
        "outputs/exercise/results.json" if exercise else "outputs/results.json", result
    )
    return {
        b: {"class_metrics": v["class_metrics"], "area_metrics": v["area_metrics"]}
        for b, v in policies.items()
    }

Test the effect of units

Projection versus reweighting

Common error: a reduction in one objective hides the other; show both metrics.

Invariant: report distinguishes direction, norm, and downstream quality.

File src/pcgrad_multitache/exercise.py

from pathlib import Path
from .config import read_json, save_json, sha
from .evaluate import paired_family_bootstrap


def begin(config):
    path = Path("outputs/exercise/protected-main.json")
    if path.exists():
        raise ValueError("Preserve existing exercise")
    files = (
        [
            Path("configs/minimum.yaml"),
            Path("outputs/results.json"),
            Path("outputs/learning/normalization.json"),
        ]
        + list(Path("outputs/inference").glob("*.json"))
        + list(Path("assets/checkpoints").glob("*.safetensors"))
    )
    identities = {str(p): sha(p) for p in files}
    save_json(path, identities)
    return {
        "protected_files": len(identities),
        "exercise": "Scaleareaobjectiveby100; sameinitialization/data/optimizer/1980updates",
    }


def finish(config):
    protected = read_json("outputs/exercise/protected-main.json")
    if any(sha(p) != h for p, h in protected.items()):
        raise ValueError("Exercise changed main artifacts")
    main = read_json("outputs/results.json")["policies"]
    extra = read_json("outputs/exercise/results.json")["policies"]
    summary = {}
    paired = {}
    for method in ["weighted", "pcgrad"]:
        a = main[method]
        b = extra[method + "-x100"]
        paired[method] = paired_family_bootstrap(a["families"], b["families"])
        for branch in [method, method + "-x100"]:
            r = read_json(f"outputs/learning/{branch}/continuous.json")
            first = r["history"][0]
            if (
                r["updates"] != 1980
                or r["identity"]["common_initial_sha256"]
                != read_json("outputs/learning/weighted/continuous.json")["identity"][
                    "common_initial_sha256"
                ]
            ):
                raise ValueError("Freshsameinitializationandbudgetrequired")
            result = {**main, **extra}[branch]
            summary[branch] = {
                "class_metrics": result["class_metrics"],
                "area_metrics": result["area_metrics"],
                "updates": r["updates"],
                "area_scale": r["identity"]["area_scale"],
                "first_update_diagnostics": first["diagnostic"],
                "final_model_identity": r["final_model_identity"],
                "seconds": r["seconds"],
            }
    if len({r["final_model_identity"] for r in summary.values()}) != 4:
        raise ValueError("Distinctactuallearnedbranchstatesrequired")
    report = {
        "status": "PASS",
        "main_unchanged": True,
        "protected_sha256": protected,
        "branches": summary,
        "paired_family_bootstrap": paired,
        "data_or_normalization_changed": False,
        "only_area_objective_rescaled": True,
        "same_optimizer": True,
        "quality_gain_required": False,
    }
    save_json("outputs/exercise/scale-losses.json", report)
    return {
        k: v for k, v in report.items() if k not in ["protected_sha256", "branches"]
    }

Sources

Gradient Surgery for Multi-Task Learning, algorithm 1, and the authors’ frozen TensorFlow code were reviewed. This practical exercise’s PyTorch implementation is original and its explicit conventions differ from the historical trainer. The authors’ repository is under MIT. Determinism rules match the project’s locked PyTorch runtime; no identical cross-hardware result is promised.

Development observations

Values recalculated from this run's raw outputs. They are not performance thresholds; the path from the ZIP recalculates them on the same populations.

{
  "status": "PASS",
  "topic": 54,
  "images": 6000,
  "test_images": 900,
  "test_families": 45,
  "learned_branches": 5,
  "updates_per_branch": 1980,
  "separate_network_updates_each": 1980,
  "exact_resume_chains": 5,
  "retained_resume_states": 15,
  "full_vector_epochs": [
    1,
    15,
    30
  ],
  "metrics": {
    "separate": {
      "class": {
        "cases": 900,
        "confusion_matrix": [
          [
            298,
            2,
            0
          ],
          [
            0,
            300,
            0
          ],
          [
            6,
            0,
            294
          ]
        ],
        "class_support": [
          300,
          300,
          300
        ],
        "per_class_f1": [
          0.9867549668874173,
          0.9966777408637874,
          0.98989898989899
        ],
        "macro_f1": 0.9911105658833982,
        "correct": 892,
        "accuracy": 0.9911111111111112
      },
      "area": {
        "cases": 900,
        "mae_percentage_points": 0.15215949880065946,
        "rmse_percentage_points": 0.17987498195941423,
        "prediction_outside_unit_interval": 0,
        "prediction_clipping": false
      }
    },
    "weighted": {
      "class": {
        "cases": 900,
        "confusion_matrix": [
          [
            279,
            14,
            7
          ],
          [
            53,
            247,
            0
          ],
          [
            20,
            1,
            279
          ]
        ],
        "class_support": [
          300,
          300,
          300
        ],
        "per_class_f1": [
          0.8558282208588958,
          0.8790035587188612,
          0.9522184300341296
        ],
        "macro_f1": 0.8956834032039621,
        "correct": 805,
        "accuracy": 0.8944444444444445
      },
      "area": {
        "cases": 900,
        "mae_percentage_points": 0.1864868760149981,
        "rmse_percentage_points": 0.24498318404895442,
        "prediction_outside_unit_interval": 0,
        "prediction_clipping": false
      }
    },
    "pcgrad": {
      "class": {
        "cases": 900,
        "confusion_matrix": [
          [
            253,
            43,
            4
          ],
          [
            21,
            279,
            0
          ],
          [
            38,
            2,
            260
          ]
        ],
        "class_support": [
          300,
          300,
          300
        ],
        "per_class_f1": [
          0.826797385620915,
          0.8942307692307693,
          0.9219858156028369
        ],
        "macro_f1": 0.8810046568181736,
        "correct": 792,
        "accuracy": 0.88
      },
      "area": {
        "cases": 900,
        "mae_percentage_points": 0.4766933405951065,
        "rmse_percentage_points": 0.5675124362699432,
        "prediction_outside_unit_interval": 0,
        "prediction_clipping": false
      }
    },
    "weighted-x100": {
      "class": {
        "cases": 900,
        "confusion_matrix": [
          [
            0,
            257,
            43
          ],
          [
            0,
            256,
            44
          ],
          [
            0,
            143,
            157
          ]
        ],
        "class_support": [
          300,
          300,
          300
        ],
        "per_class_f1": [
          0.0,
          0.5355648535564853,
          0.5772058823529411
        ],
        "macro_f1": 0.3709235786364755,
        "correct": 413,
        "accuracy": 0.4588888888888889
      },
      "area": {
        "cases": 900,
        "mae_percentage_points": 0.1418863554496759,
        "rmse_percentage_points": 0.1719326515726746,
        "prediction_outside_unit_interval": 0,
        "prediction_clipping": false
      }
    },
    "pcgrad-x100": {
      "class": {
        "cases": 900,
        "confusion_matrix": [
          [
            150,
            74,
            76
          ],
          [
            54,
            245,
            1
          ],
          [
            14,
            6,
            280
          ]
        ],
        "class_support": [
          300,
          300,
          300
        ],
        "per_class_f1": [
          0.5791505791505791,
          0.784,
          0.852359208523592
        ],
        "macro_f1": 0.7385032625580571,
        "correct": 675,
        "accuracy": 0.75
      },
      "area": {
        "cases": 900,
        "mae_percentage_points": 0.1476638544471312,
        "rmse_percentage_points": 0.1908630628898403,
        "prediction_outside_unit_interval": 0,
        "prediction_clipping": false
      }
    }
  },
  "scope": "Independent scalar masks/area/normalization, complete recorded gradient projections at fixed probes, shapes/identities/budgets, predictions/metrics and family bootstrap. Full Adam/RNG equality is public comparator tied to retained state hashes; no private pickle load or independent CNN backpropagation."
}

The observed results, their scope, and the proposed experiment.

Share this article