Parliamo del progetto
Intelligenza artificiale

Due compiti per un'immagine: quando i gradienti si ostacolano?

Addestrate una sola rete a riconoscere silhouette e stimarne l'area. Un esempio passo passo mostra come PCGrad proietta gradienti opposti, poi confronta questo compromesso con due reti separate.

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

Una postazione di visione fittizia riceve silhouette di pezzi e deve predirne la famiglia geometrica e l'area visibile normalizzata. Vogliamo condividere un encoder, studiando esplicitamente l'effetto di due obiettivi e delle loro unità.

Per un'immagine, mostrate la classe e l'area predette, poi seguite su un piccolo esempio il prodotto scalare di due gradienti e la loro proiezione. Confrontate rete condivisa, reti separate e variante PCGrad.

Scaricare il progetto completo, revisione r001

Il percorso del tutorial, dal caso concreto alla verifica.

Due target dalla stessa immagine

PiecesDeuxTaches-v1 contiene 300 famiglie di silhouette, 100 per classe rettangolo, ellisse e triangolo, con 20 viste per famiglia. Una partizione stratificata con seed 17 colloca 70/15/15 famiglie di ogni classe in train/validation/test: 4200/900/900 immagini. Le viste di una famiglia condividono una geometria vicina. Traslazione, angolo, scala, illuminazione e rumore cambiano; nessuna vista vicina attraversa una partizione.

Le immagini sono matrici uint8 di 64×64, divise per 255 per entrare nel CNN. I parametri del renderer, la classe e la maschera binaria restano label di audit e non sono mai feature. L'area è il numero esatto di pixel della maschera diviso per 4096. Non rappresenta mm². Il renderer rifiuta silhouette vuote o tagliate dal bordo. Gli intervalli di area delle tre classi si sovrappongono: la sola dimensione non deve definire la classe.

Media e deviazione standard della popolazione dell'area vengono calcolate sulle 4200 immagini train in FP64, salvate e poi congelate. Il target di regressione è (area−media_train)/deviazione_standard_train. La valutazione applica la trasformazione inversa. Calcolare queste statistiche con il test cambierebbe il protocollo.

Condividere parametri, mantenere due output

Il tronco originale possiede tre convoluzioni 3×3, stride 2 e padding 1, con 32/64/96 canali e ReLU. Le dimensioni spaziali passano da 64 a 32, 16 e poi 8. Un global average pooling produce 96 valori. La testa classe è Linear(96,3), la testa area Linear(96,1). Non vi sono né dropout, né batch normalization, né sigmoid sull'area.

Il tronco conta 74 208 parametri; le teste 291 e 97, cioè 74 596 parametri unici per la rete condivisa. Le due reti separate contano insieme 148 804 parametri: un tronco e una testa classe, poi un altro tronco e una testa area. Le copie hanno gli stessi valori iniziali del ramo condiviso, con storage distinti. I loro costi combinati vengono misurati. Lo stesso numero di batch non significa gli stessi FLOPs.

La loss di classe è l'entropia incrociata su tre logits. L'area standardizzata usa Huber delta=1: errore quadratico vicino a zero, crescita lineare oltre. Questi obiettivi hanno unità e curvature diverse. Il testimone condiviso ottimizza (L_classe+L_area)/2. I due modelli separati ricevono ciascuno la propria loss divisa per due, per mantenere la convenzione di scala.

Ciò che PCGrad proietta

Autograd calcola separatamente g_classe e g_area sulla lista ordinata dei parametri del tronco. Flatten concatena le loro coordinate secondo questo ordine; un gradiente mancante è un errore per un parametro dichiarato condiviso. L'opzione esplicita allow_unused consente zeri per altri usi, ma il tutorial non la attiva per mascherare una testa scollegata.

Il prodotto scalare negativo indica che le due direzioni locali si oppongono. Per ogni compito i, partiamo da una copia g_i_proj e visitiamo gli altri compiti j in un ordine casuale salvato. Se g_i_proj·g_j_original<0, sottraiamo [g_i_proj·g_j_original / ||g_j_original||²] g_j_original. I riferimenti restano i gradienti originali. Una norma esattamente nulla viene ignorata; non vengono introdotti né divisione per zero né epsilon nascosto.

Il gradiente assegnato al tronco è la media dei due vettori proiettati. Le teste ricevono i propri gradienti ordinari divisi per due. Non proiettiamo artificialmente una testa contro il gradiente inesistente dell'altro compito. Il testimone weighted calcola anch'esso i due gradienti per la diagnosi, poi la loro media; un test confronta questa operazione con il backward della media delle due loss.

L'esempio g1=(1,0), g2=(−1,1) dà g1_proj=(0,5;0,5), g2_proj=(0;1), media=(0,25;0,75). Questi vettori sono costruiti per spiegare il calcolo. Non provano che tutti i batch del CNN siano conflittuali. Il percorso ispeziona separatamente un vero batch iniziale e conserva prodotti/norme durante gli addestramenti.

Con due compiti, ogni compito incontra un solo altro riferimento: qui l'ordine non ha alcun effetto. Il codice accetta più compiti e un test con tre compiti verifica la ripresa dell'RNG e l'uso dei riferimenti originali. Proiezioni successive su più di due compiti non garantiscono la scomparsa di tutti i conflitti finali.

Questa variante è limitata al tronco condiviso e aggrega per media, allineata al testimone. Il repository TensorFlow storico aggrega per somma e la sua riga di mescolamento non usa il valore restituito; seguiamo l'ordine esplicito descritto dall'algoritmo dell'articolo. Anche il caso di norma nulla viene trattato esplicitamente qui. Non viene rivendicata alcuna equivalenza del trainer TensorFlow completo.

Addestrare, ispezionare e riprendere

I tre rami principali partono dallo stesso file iniziale. Ciascuno esegue 30 epoche, batch 64, ultimo batch di 40 immagini: 66 update per epoca e 1980 in totale. Le due reti separate eseguono ciascuna questi 1980 update. AdamW usa lr=0,001, betas=(0,9;0,999), eps=1e−8 e weight_decay=0,0001, senza scheduler né clipping dei gradienti. La validazione segue le loss, senza selezione del checkpoint migliore; lo stato finale è fissato in anticipo.

Il sampler CPU, seed 117, produce gli stessi ordini per tutti i rami. L'RNG di proiezione, seed 217, viene salvato anche se l'ordine a due compiti è banale. Ogni batch condiviso conserva prodotti scalari, norme originali/proiettate e ordine di proiezione. I primi batch delle epoche 1, 15 e 30 conservano i vettori completi, inclusi i gradienti delle teste. Questo sottoinsieme di sonde è fissato prima dell'addestramento; gli altri batch conservano gli scalari.

Ogni ramo confronta interruzione dopo 12 epoche (792 update), 30 epoche continue e ripresa delle ultime 18 (1188 nuovi update). Pesi, Adam, sampler, RNG di sistema, RNG di proiezione e storie numeriche devono essere esattamente uguali. Le durate restano fuori dal confronto. Gli stati locali gzip livello 1 vengono conservati con i loro hash; gli stati finali sono inoltre salvati in Safetensors e davvero ricaricati prima dell'inferenza. Il programma non carica pickle di terzi.

Valutare due unità

Ogni ramo predice le stesse 900 immagini di 45 famiglie riservate. Macro-F1 e matrice di confusione riguardano le tre classi. L'errore di area è espresso in punti percentuali: un errore di frazione 0,01 vale un punto. Pubblichiamo MAE e RMSE, oltre al numero di predizioni fuori da [0,1]. Non vengono sottoposte a clipping per migliorare la tabella.

Ogni famiglia comporta 20 immagini; riportiamo la sua accuracy di classe e la sua MAE di area. Un bootstrap appaiato estrae 45 famiglie con reinserimento, 1000 volte, seed 317. Gli intervalli riguardano le differenze di accuracy e di MAE, non una macro-F1 rinominata. I limiti sono i valori ordinati ai ranghi 25 e 975 su 1000. Questo descrive la variabilità tra famiglie di questo corpus, non tra seed di addestramento.

Non esiste un punteggio composito improvvisato tra F1 e area. Un guadagno su un compito può costare sull'altro. Tempi dei rami, validazione, inferenza, numero di parametri e memoria realmente osservata accompagnano la qualità. I costi di ripresa e dell'esercizio sono aggiuntivi. Il bootstrap non trasforma questo corpus fittizio in una valutazione industriale.

Esercizio: una loss cento volte più pesante

Due nuovi rami weighted-x100 e pcgrad-x100 ripartono realmente dall'inizializzazione, con gli stessi dati, normalizzazione, ordine e 1980 update. Solo la loss di area viene moltiplicata per 100, quindi anche il suo gradiente di testa. Il codice differenzia la Huber non ponderata, poi moltiplica esplicitamente i suoi gradienti per il coefficiente prima della proiezione e della media. Questa forma matematicamente equivalente fissa anche l'ordine numerico FP32 ed evita differenze di cancellazione dovute a una riscalatura prima della retropropagazione. Hanno stati di ripresa e pesi finali propri. I risultati principali sono protetti da impronte.

Confrontate le norme fin dal primo batch, le proiezioni e i due compiti test. PCGrad rimuove una componente opposta; non normalizza automaticamente l'influenza dei compiti. AdamW può a sua volta attenuare alcuni effetti di una riscalatura uniforme: i risultati dipendono anche da questo ottimizzatore. Non vengono imposti né un guadagno di PCGrad né un peggioramento della variante ×100.

Limiti

Le silhouette sintetiche sono regolari e i due compiti possono essere compatibili. Un prodotto scalare negativo è una diagnosi locale; non basta a predire il compromesso test. Un solo seed non misura la variabilità dell'apprendimento. Compiti realmente indipendenti o vincoli di distribuzione distinti possono giustificare due reti separate.

Preparare due target coerenti

Unità e partizioni per pezzo

Errore frequente: la normalizzazione include il test: ricalcolare sul train e congelarne le statistiche.

Invariante: area in [0,1], famiglie disgiunte e classi che coprono le stesse dimensioni.

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

Formare la baseline condivisa

Un tronco, due teste

Errore frequente: una testa è scollegata dal tronco: testare separatamente entrambi i gradienti.

Invariante: due output finiti e parametri condivisi identificabili.

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
    )

Codificare PCGrad senza helper opaco

Proiezione sul sottospazio condiviso

Errore frequente: riutilizzare gradienti modificati come riferimenti: copiare gli originali.

Invariante: fixture vettoriale esatta e nessun NaN per gradiente nullo.

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

Addestrare e riprendere i rami

Confronto a batch costanti

Errore frequente: l'accumulo di grafi trattiene l'intera epoca: liberare dopo ogni update.

Invariante: i parametri cambiano; la ripresa ripristina la permutazione delle proiezioni.

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

Interpretare i due compiti

Nessun punteggio composito improvvisato

Errore frequente: confrontare area standardizzata con pixel: applicare l'inverso della normalizzazione.

Invariante: tutti i risultati corrispondono agli stessi 900 casi previsti.

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

Testare l'effetto delle unità

Proiezione rispetto alla ripesatura

Errore frequente: una diminuzione di un obiettivo nasconde l'altro: mostrare entrambe le metriche.

Invariante: il rapporto distingue direzione, norma e qualità a valle.

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"]
    }

Fonti

Gradient Surgery for Multi-Task Learning, algoritmo 1, e il codice TensorFlow degli autori fissato sono stati riletti. L'implementazione PyTorch di questo tutorial è originale e le sue convenzioni esplicite differiscono dal trainer storico. Il repository degli autori è sotto MIT. Le regole di determinismo usate corrispondono al runtime PyTorch bloccato del progetto; non viene promesso alcun risultato identico su hardware diversi.

Osservazioni di sviluppo

Valori ricalcolati dalle uscite grezze di questo percorso. Non sono soglie di guadagno; il percorso dallo ZIP li ricalcola sulle stesse popolazioni.

{
  "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."
}

I risultati osservati, la loro portata e l'esperimento proposto.

Condividi questo articolo