Parlons de votre projet
Intelligence artificielle

Deux tâches pour une image : quand les gradients se gênent-ils ?

Vous entraînez un seul réseau à reconnaître des silhouettes et à estimer leur aire. Un exemple pas à pas montre comment PCGrad projette des gradients opposés, puis compare ce compromis à deux réseaux séparés.

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

Un poste de vision fictif reçoit des silhouettes de pièces et doit prédire leur famille géométrique et leur aire visible normalisée. Nous voulons partager un encodeur, tout en étudiant explicitement l'effet de deux objectifs et de leurs unités.

Pour une image, afficher la classe et l'aire prédites, puis suivre sur un petit exemple le produit scalaire de deux gradients et leur projection. Comparer réseau partagé, réseaux séparés et variante PCGrad.

Télécharger le projet complet, révision r001

Le parcours du tutoriel, du cas concret à la vérification.

Deux cibles issues de la même image

PiecesDeuxTaches-v1 contient 300 familles de silhouettes, 100 par classe rectangle, ellipse et triangle, avec 20 vues par famille. Une partition stratifiée de graine 17 place 70/15/15 familles de chaque classe dans train/validation/test : 4200/900/900 images. Les vues d’une famille partagent une géométrie proche. Translation, angle, échelle, éclairage et bruit changent ; aucune vue voisine ne traverse une partition.

Les images sont des matrices uint8 de 64×64, divisées par 255 pour entrer dans le CNN. Les paramètres du renderer, la classe et le masque binaire restent des labels d’audit et ne sont jamais des features. L’aire est le nombre exact de pixels du masque divisé par 4096. Elle ne représente pas des mm². Le renderer refuse les silhouettes vides ou coupées par le bord. Les plages d’aire des trois classes se recouvrent : la taille seule ne doit pas définir la classe.

La moyenne et l’écart type population de l’aire sont calculés sur les 4200 images train en FP64, sauvegardés puis gelés. La cible de régression est (aire−moyenne_train)/écart_type_train. L’évaluation applique la transformation inverse. Calculer ces statistiques avec le test changerait le protocole.

Partager des paramètres, conserver deux sorties

Le tronc original possède trois convolutions 3×3, stride 2 et padding 1, avec 32/64/96 canaux et ReLU. Les tailles spatiales passent de 64 à 32, 16 puis 8. Un pooling moyen global donne 96 valeurs. La tête classe est Linear(96,3), la tête aire Linear(96,1). Il n’y a ni dropout, ni batch normalization, ni sigmoid sur l’aire.

Le tronc compte 74 208 paramètres ; les têtes 291 et 97, soit 74 596 paramètres uniques pour le réseau partagé. Les deux réseaux séparés comptent ensemble 148 804 paramètres : un tronc et une tête classe, puis un autre tronc et une tête aire. Les copies ont les mêmes valeurs initiales que la branche partagée, avec des stockages distincts. Leurs coûts combinés sont mesurés. Même nombre de batches ne signifie pas mêmes FLOPs.

La loss de classe est l’entropie croisée sur trois logits. L’aire standardisée utilise Huber delta=1 : erreur quadratique près de zéro, croissance linéaire au-delà. Ces objectifs ont des unités et courbures différentes. Le témoin partagé optimise (L_classe+L_aire)/2. Les deux modèles séparés reçoivent chacun leur propre loss divisée par deux, pour conserver la convention d’échelle.

Ce que PCGrad projette

Autograd calcule séparément g_classe et g_aire sur la liste ordonnée des paramètres du tronc. Flatten concatène leurs coordonnées selon cet ordre ; un gradient manquant est une erreur pour un paramètre déclaré partagé. L’option explicite allow_unused permet des zéros pour d’autres usages, mais le TP ne l’active pas pour masquer une tête déconnectée.

Le produit scalaire négatif signifie que les deux directions locales s’opposent. Pour chaque tâche i, on part d’une copie g_i_proj et on visite les autres tâches j dans un ordre aléatoire sauvegardé. Si g_i_proj·g_j_original<0, on soustrait [g_i_proj·g_j_original / ||g_j_original||²] g_j_original. Les références restent les gradients originaux. Une norme exactement nulle est ignorée ; aucune division par zéro ni epsilon caché n’est introduit.

Le gradient attribué au tronc est la moyenne des deux vecteurs projetés. Les têtes reçoivent leurs gradients ordinaires propres divisés par deux. On ne projette pas artificiellement une tête contre le gradient inexistant de l’autre tâche. Le témoin weighted calcule aussi les deux gradients pour le diagnostic, puis leur moyenne ; un test compare cette opération au backward de la moyenne des deux pertes.

L’exemple g1=(1,0), g2=(−1,1) donne g1_proj=(0,5;0,5), g2_proj=(0;1), moyenne=(0,25;0,75). Ces vecteurs sont construits pour expliquer le calcul. Ils ne prouvent pas que tous les batches du CNN sont conflictuels. Le parcours inspecte séparément un vrai batch initial et conserve les produits/normes pendant les entraînements.

Avec deux tâches, chaque tâche ne rencontre qu’une autre référence : l’ordre n’a aucun effet ici. Le code accepte davantage de tâches et un test à trois tâches vérifie la reprise du RNG et l’usage des références originales. Des projections successives sur plus de deux tâches ne garantissent pas la disparition de tous les conflits finaux.

Cette variante est restreinte au tronc partagé et agrège par moyenne, alignée sur le témoin. Le dépôt TensorFlow historique agrège par somme et sa ligne de mélange n’utilise pas la valeur retournée ; nous suivons l’ordre explicite décrit par l’algorithme de l’article. Le cas de norme nulle est également traité explicitement ici. Aucune équivalence du trainer TensorFlow complet n’est revendiquée.

Entraîner, inspecter et reprendre

Les trois branches principales partent du même fichier initial. Chacune effectue 30 époques, batch 64, dernier batch de 40 images : 66 updates par époque et 1980 au total. Les deux réseaux séparés effectuent chacun ces 1980 updates. AdamW utilise lr=0,001, betas=(0,9;0,999), eps=1e−8 et weight_decay=0,0001, sans scheduler ni clipping des gradients. La validation suit les pertes, sans sélection du meilleur checkpoint ; l’état final est fixé à l’avance.

Le sampler CPU, graine 117, produit les mêmes ordres pour toutes les branches. Le RNG de projection, graine 217, est sauvegardé même si l’ordre à deux tâches est trivial. Chaque batch partagé conserve produits scalaires, normes originales/projetées et ordre de projection. Les premiers batches des époques 1, 15 et 30 conservent les vecteurs complets, dont les gradients des têtes. Ce sous-ensemble de sondes est fixé avant entraînement ; les autres batches gardent leurs scalaires.

Chaque branche compare interruption après 12 époques (792 updates), 30 époques continues et reprise des 18 dernières (1188 nouvelles updates). Poids, Adam, sampler, RNG système, RNG de projection et historiques numériques doivent être exactement égaux. Les durées restent hors comparaison. Les états locaux gzip niveau 1 sont conservés avec leurs hashes ; les états finaux sont aussi sauvegardés en Safetensors et réellement rechargés avant l’inférence. Le programme ne charge pas de pickle tiers.

Évaluer deux unités

Chaque branche prédit les mêmes 900 images de 45 familles réservées. Macro-F1 et matrice de confusion concernent les trois classes. L’erreur d’aire est exprimée en points de pourcentage : une erreur de fraction 0,01 vaut un point. On publie MAE et RMSE, ainsi que le nombre de prédictions hors [0,1]. Elles ne sont pas clippées pour améliorer le tableau.

Chaque famille comporte 20 images ; on rapporte son accuracy de classe et sa MAE d’aire. Un bootstrap apparié tire 45 familles avec remise, 1000 fois, graine 317. Les intervalles portent sur les différences d’accuracy et de MAE, pas sur une macro-F1 renommée. Les bornes sont les valeurs triées aux rangs 25 et 975 sur 1000. Cela décrit la variabilité entre familles de ce corpus, pas entre graines d’entraînement.

Il n’y a pas de score composite improvisé entre F1 et aire. Un gain sur une tâche peut coûter sur l’autre. Les temps de branches, validation, inference, nombre de paramètres et mémoire réellement observée accompagnent la qualité. Les coûts de reprise et d’exercice sont supplémentaires. Le bootstrap ne transforme pas ce corpus fictif en évaluation industrielle.

Exercice : une perte cent fois plus lourde

Deux nouvelles branches weighted-x100 et pcgrad-x100 repartent réellement de l’initialisation, avec les mêmes données, normalisation, ordre et 1980 updates. Seule la loss d’aire est multipliée par 100, donc aussi son gradient de tête. Le code différentie la Huber non pondérée, puis multiplie explicitement ses gradients par le coefficient avant projection et moyenne. Cette forme mathématiquement équivalente fixe aussi l’ordre numérique FP32 et évite les différences de cancellation d’une remise à l’échelle avant rétropropagation. Elles ont leurs propres états de reprise et poids finaux. Les résultats principaux sont protégés par empreintes.

Comparer les normes dès le premier batch, les projections et les deux tâches test. PCGrad retire une composante opposée ; il ne normalise pas automatiquement l’influence des tâches. AdamW peut lui-même atténuer certains effets d’une remise à l’échelle uniforme : les résultats dépendent aussi de cet optimiseur. Aucun gain de PCGrad ni dégradation de la variante ×100 n’est imposé.

Limites

Les silhouettes synthétiques sont régulières et les deux tâches peuvent être compatibles. Un produit scalaire négatif est un diagnostic local ; il ne suffit pas à prédire le compromis test. Une seule graine ne mesure pas la variabilité d’apprentissage. Des tâches réellement indépendantes ou des contraintes de déploiement distinctes peuvent justifier deux réseaux séparés.

Préparer deux cibles cohérentes

Unités et partitions par pièce

Erreur fréquente : La normalisation inclut test : recalculer sur train et geler ses statistiques.

Invariant : Aire dans [0,1], familles disjointes et classes recouvrant les mêmes tailles.

Fichier 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

Former la baseline partagée

Un tronc, deux têtes

Erreur fréquente : Une tête est détachée du tronc : tester les deux gradients séparément.

Invariant : Deux sorties finies et paramètres partagés identifiables.

Fichier 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
    )

Coder PCGrad sans helper opaque

Projection sur le sous-espace partagé

Erreur fréquente : Réutilisation de gradients modifiés comme références : copier les originaux.

Invariant : Fixture vectorielle exacte et aucun NaN pour gradient nul.

Fichier 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,
    }

Entraîner et reprendre les branches

Comparaison à batches constants

Erreur fréquente : Accumulation de graphes retient toute l'époque : libérer après chaque update.

Invariant : Paramètres changent ; reprise restaure la permutation des projections.

Fichier 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

Interpréter les deux tâches

Pas de score composite improvisé

Erreur fréquente : Comparer aire standardisée à pixels : appliquer inverse de normalisation.

Invariant : Tous les résultats correspondent aux mêmes 900 cas prévus.

Fichier 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()
    }

Tester l'effet des unités

Projection versus repondération

Erreur fréquente : Une baisse d'un objectif masque l'autre : montrer les deux métriques.

Invariant : Rapport distingue direction, norme et qualité aval.

Fichier 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, algorithme 1, et code TensorFlow des auteurs figé ont été relus. L’implémentation PyTorch de ce TP est originale et ses conventions explicites diffèrent du trainer historique. Le dépôt des auteurs est sous MIT. Les règles de déterminisme utilisées correspondent au runtime PyTorch verrouillé du projet ; aucun résultat intermatériel identique n’est promis.

Observations de développement

Valeurs recalculées depuis les sorties brutes de ce parcours. Elles ne sont pas des seuils de gain ; le parcours depuis le ZIP les recalcule sur les mêmes 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."
}

Les résultats observés, leur portée et l'expérience proposée.

Partager cet article