Un puesto de visión ficticio recibe siluetas de piezas y debe predecir su familia geométrica y su área visible normalizada. Queremos compartir un codificador y estudiar explícitamente el efecto de dos objetivos y sus unidades.
Para una imagen, muestre la clase y el área predichas, y siga en un ejemplo pequeño el producto escalar de dos gradientes y su proyección. Compare red compartida, redes separadas y variante PCGrad.
Descargar el proyecto completo, revisión r001
Dos objetivos procedentes de la misma imagen
PiecesDeuxTaches-v1 contiene 300 familias de siluetas, 100 por clase: rectángulo, elipse y triángulo, con 20 vistas por familia. Una partición estratificada con semilla 17 coloca 70/15/15 familias de cada clase en entrenamiento/validación/prueba: 4200/900/900 imágenes. Las vistas de una familia comparten una geometría similar. Cambian traslación, ángulo, escala, iluminación y ruido; ninguna vista vecina atraviesa una partición.
Las imágenes son matrices uint8 de 64×64, divididas por 255 para entrar en el CNN. Los parámetros del renderer, la clase y la máscara binaria permanecen como etiquetas de auditoría y nunca son features. El área es el número exacto de píxeles de la máscara dividido por 4096. No representa mm². El renderer rechaza siluetas vacías o cortadas por el borde. Los intervalos de área de las tres clases se solapan: el tamaño por sí solo no debe definir la clase.
La media y la desviación estándar poblacional del área se calculan sobre las 4200 imágenes de entrenamiento en FP64, se guardan y se congelan. El objetivo de regresión es (área−media_entrenamiento)/desviación_estándar_entrenamiento. La evaluación aplica la transformación inversa. Calcular estas estadísticas con la prueba cambiaría el protocolo.
Compartir parámetros, conservar dos salidas
El tronco original tiene tres convoluciones 3×3, stride 2 y padding 1, con 32/64/96 canales y ReLU. Los tamaños espaciales pasan de 64 a 32, 16 y luego 8. Un pooling medio global devuelve 96 valores. La cabeza de clase es Linear(96,3), la de área Linear(96,1). No hay dropout, batch normalization ni sigmoid sobre el área.
El tronco cuenta 74 208 parámetros; las cabezas 291 y 97, un total de 74 596 parámetros únicos para la red compartida. Las dos redes separadas suman 148 804 parámetros: un tronco y una cabeza de clase, y otro tronco con una cabeza de área. Las copias tienen los mismos valores iniciales que la rama compartida, con almacenamientos distintos. Se miden sus costes combinados. El mismo número de batches no implica los mismos FLOPs.
La pérdida de clase es la entropía cruzada sobre tres logits. El área estandarizada usa Huber delta=1: error cuadrático cerca de cero y crecimiento lineal más allá. Estos objetivos tienen unidades y curvaturas diferentes. El control compartido optimiza (L_clase+L_área)/2. Los dos modelos separados reciben cada uno su propia pérdida dividida por dos, para conservar la convención de escala.
Lo que proyecta PCGrad
Autograd calcula por separado g_clase y g_área sobre la lista ordenada de parámetros del tronco. Flatten concatena sus coordenadas en ese orden; un gradiente ausente es un error para un parámetro declarado compartido. La opción explícita allow_unused permite ceros para otros usos, pero el TP no la activa para ocultar una cabeza desconectada.
Un producto escalar negativo significa que las dos direcciones locales se oponen. Para cada tarea i se parte de una copia g_i_proj y se visitan las demás tareas j en un orden aleatorio guardado. Si g_i_proj·g_j_original<0, se resta [g_i_proj·g_j_original / ||g_j_original||²] g_j_original. Las referencias siguen siendo los gradientes originales. Una norma exactamente nula se ignora; no se introduce ninguna división por cero ni epsilon oculto.
El gradiente atribuido al tronco es la media de los dos vectores proyectados. Las cabezas reciben sus propios gradientes ordinarios divididos por dos. No se proyecta artificialmente una cabeza contra el gradiente inexistente de la otra tarea. El control weighted también calcula los dos gradientes para el diagnóstico y después su media; una prueba compara esta operación con el backward de la media de ambas pérdidas.
El ejemplo g1=(1,0), g2=(−1,1) da g1_proj=(0,5;0,5), g2_proj=(0;1), media=(0,25;0,75). Estos vectores se construyen para explicar el cálculo. No demuestran que todos los batches del CNN sean conflictivos. El recorrido inspecciona por separado un batch inicial real y conserva los productos/normas durante los entrenamientos.
Con dos tareas, cada tarea solo encuentra otra referencia: el orden no tiene efecto aquí. El código admite más tareas y una prueba con tres tareas verifica la reanudación del RNG y el uso de las referencias originales. Las proyecciones sucesivas con más de dos tareas no garantizan la desaparición de todos los conflictos finales.
Esta variante se limita al tronco compartido y agrega por media, alineada con el control. El repositorio histórico de TensorFlow agrega por suma y su línea de mezcla no utiliza el valor devuelto; seguimos el orden explícito descrito por el algoritmo del artículo. El caso de norma nula también se trata explícitamente aquí. No se afirma equivalencia con el trainer completo de TensorFlow.
Entrenar, inspeccionar y reanudar
Las tres ramas principales parten del mismo archivo inicial. Cada una realiza 30 épocas, batch 64 y último batch de 40 imágenes: 66 actualizaciones por época y 1980 en total. Las dos redes separadas realizan cada una esas 1980 actualizaciones. AdamW usa lr=0,001, betas=(0,9;0,999), eps=1e−8 y weight_decay=0,0001, sin scheduler ni recorte de gradientes. La validación sigue las pérdidas, sin seleccionar el mejor checkpoint; el estado final se fija de antemano.
El sampler CPU, semilla 117, produce los mismos órdenes para todas las ramas. El RNG de proyección, semilla 217, se guarda aunque el orden con dos tareas sea trivial. Cada batch compartido conserva productos escalares, normas originales/proyectadas y orden de proyección. Los primeros batches de las épocas 1, 15 y 30 conservan los vectores completos, incluidos los gradientes de las cabezas. Este subconjunto de sondas se fija antes del entrenamiento; los demás batches conservan sus escalares.
Cada rama compara una interrupción tras 12 épocas (792 actualizaciones), 30 épocas continuas y la reanudación de las 18 últimas (1188 actualizaciones nuevas). Pesos, Adam, sampler, RNG del sistema, RNG de proyección e historiales numéricos deben ser exactamente iguales. Las duraciones quedan fuera de la comparación. Los estados locales gzip nivel 1 se conservan con sus hashes; los estados finales también se guardan en Safetensors y se recargan realmente antes de la inferencia. El programa no carga ningún pickle de terceros.
Evaluar dos unidades
Cada rama predice las mismas 900 imágenes de 45 familias reservadas. Macro-F1 y matriz de confusión se refieren a las tres clases. El error de área se expresa en puntos porcentuales: un error de fracción 0,01 vale un punto. Se publican MAE y RMSE, así como el número de predicciones fuera de [0,1]. No se recortan para mejorar la tabla.
Cada familia contiene 20 imágenes; se informa de su accuracy de clase y su MAE de área. Un bootstrap emparejado extrae 45 familias con reemplazo, 1000 veces, semilla 317. Los intervalos se aplican a las diferencias de accuracy y MAE, no a una macro-F1 renombrada. Los límites son los valores ordenados en los rangos 25 y 975 de 1000. Esto describe la variabilidad entre familias de este corpus, no entre semillas de entrenamiento.
No hay una puntuación compuesta improvisada entre F1 y área. Una ganancia en una tarea puede costar en la otra. Los tiempos de ramas, validación, inferencia, número de parámetros y memoria realmente observada acompañan a la calidad. Los costes de reanudación y ejercicio son adicionales. El bootstrap no convierte este corpus ficticio en una evaluación industrial.
Ejercicio: una pérdida cien veces más pesada
Dos ramas nuevas, weighted-x100 y pcgrad-x100, parten realmente de la inicialización, con los mismos datos, normalización, orden y 1980 actualizaciones. Solo la pérdida de área se multiplica por 100, y también su gradiente de cabeza. El código diferencia la Huber sin ponderar y después multiplica explícitamente sus gradientes por el coeficiente antes de la proyección y la media. Esta forma matemáticamente equivalente también fija el orden numérico FP32 y evita diferencias de cancelación debidas a una reescalada antes de la retropropagación. Tienen sus propios estados de reanudación y pesos finales. Los resultados principales están protegidos por huellas.
Compare las normas desde el primer batch, las proyecciones y las dos tareas de prueba. PCGrad elimina una componente opuesta; no normaliza automáticamente la influencia de las tareas. AdamW puede atenuar por sí mismo ciertos efectos de una reescalada uniforme: los resultados también dependen de este optimizador. No se impone ninguna ganancia de PCGrad ni degradación de la variante ×100.
Límites
Las siluetas sintéticas son regulares y las dos tareas pueden ser compatibles. Un producto escalar negativo es un diagnóstico local; no basta para predecir el compromiso en la prueba. Una sola semilla no mide la variabilidad del aprendizaje. Tareas realmente independientes o restricciones de despliegue distintas pueden justificar dos redes separadas.
Preparar dos objetivos coherentes
Unidades y particiones por pieza
Error frecuente: la normalización incluye la prueba; recalcular sobre entrenamiento y congelar sus estadísticas.
Invariante: área en [0,1], familias disjuntas y clases que cubren los mismos tamaños.
Archivo 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
Formar la baseline compartida
Un tronco, dos cabezas
Error frecuente: una cabeza se desconecta del tronco; probar los dos gradientes por separado.
Invariante: dos salidas finitas y parámetros compartidos identificables.
Archivo 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
)
Codificar PCGrad sin helper opaco
Proyección sobre el subespacio compartido
Error frecuente: reutilizar gradientes modificados como referencias; copiar los originales.
Invariante: fixture vectorial exacta y ningún NaN para gradiente nulo.
Archivo 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,
}
Entrenar y reanudar las ramas
Comparación con batches constantes
Error frecuente: la acumulación de grafos retiene toda la época; liberar después de cada actualización.
Invariante: los parámetros cambian; la reanudación restaura la permutación de las proyecciones.
Archivo 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
Interpretar las dos tareas
Sin puntuación compuesta improvisada
Error frecuente: comparar área estandarizada con píxeles; aplicar la inversa de la normalización.
Invariante: todos los resultados corresponden a los mismos 900 casos previstos.
Archivo 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()
}
Probar el efecto de las unidades
Proyección frente a reponderación
Error frecuente: una bajada de un objetivo oculta el otro; mostrar ambas métricas.
Invariante: el informe distingue dirección, norma y calidad posterior.
Archivo 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"]
}
Fuentes
Gradient Surgery for Multi-Task Learning, algoritmo 1, y código TensorFlow fijado de los autores se han revisado. La implementación PyTorch de este TP es original y sus convenciones explícitas difieren del trainer histórico. El repositorio de los autores está bajo MIT. Las reglas de determinismo usadas corresponden al runtime PyTorch bloqueado del proyecto; no se promete ningún resultado idéntico entre equipos.
Observaciones de desarrollo
Valores recalculados a partir de las salidas brutas de este recorrido. No son umbrales de ganancia; el recorrido desde el ZIP los recalcula sobre las mismas poblaciones.
{
"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."
}