SyncValsverifier → artifact → classifier → verdict
SyncVals · Trajectory

simjeb-bracket-fea-mass-prediction-real

claude-code claude-opus-4-8 ✗ failed GOOD_FAILURE ↑ View task
Solved from the instruction alone, tests/ and solution/ were withheld from the agent's workspace and restored only for grading.
Reward = tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.
Classification , post-hoc; cannot change the reward
GOOD_FAILUREHonest miss, the agent ran correctly but couldn't solve it. Expected for a hard task; the task is sound.
SubtypeIncomplete Model Training / Insufficient Optimization
EvidenceAgent achieved rel-L2 score of 0.239 but required ≤0.204 to pass. Test output shows: 'Honest out-of-sample estimate (train on 100, test on unseen 48): rel_L2 ~ 0.239 | MAE ~ 0.151 kg | worst-case abs err ~ 0.65 kg'. Reward policy requires meeting all three metrics: rel_l2≤0.204, mae≤0.107, max_abs_err≤0.8. Agent was within range on MAE (0.151 > 0.107) and max_abs_err (0.65 < 0.8) but missed the primary rel-L2 target. Verifier output: 'FINAL DELIVERABLE: /app/outputs/model.pt (ready)' and 'reward = 0.0 (FAIL)'.
Root causeThe agent implemented a sound training pipeline with proper normalization, augmentation, EMA, and cosine schedules, achieving rel-L2 ≈0.24. However, this max-pool PointNet architecture has fundamental capacity limits (trains to floor ~0.12 even without regularization on 100 samples), and beating the target threshold of 0.204 rel-L2 requires more sophisticated training (the reference solution trains for 2200 epochs with careful hyperparameter tuning and achieves ~0.04-0.05). The agent ran only ~400 epochs and exhausted practical improvements within their approach, falling ~0.035 rel-L2 short of the passing threshold.
RecommendationN/A - task is fine. This is a well-specified hard benchmark task where the passing threshold (rel-L2 ≤0.204) is achievable but requires expert-level hyperparameter optimization and training discipline (the golden solution trains 2200 epochs, uses EMA decay 0.998 starting at epoch 20, manages learning rate carefully, and combines train+val+test data for normalization). The agent's failure is due to exploring a reasonable but insufficient training strategy given the optimization challenges, not due to task underspecification."
Trajectory
Tool-by-tool agent trajectory
314 tool calls · 3 tool types · 314 steps
You are given point-cloud samples of 3D jet-engine bracket designs along with each bracket's FEA-derived mass (kg). Each point cloud is an unordered set of 2048 surface points in R^3; coordinates are pre-centered and rescaled so the axis-aligned bounding box of each bracket fits inside the unit cube. The mapping from geometry to mass is not given to you - you have to learn it from the data. Train a model that takes a `[2048, 3]` point cloud and predicts the scalar mass value. Your model is then applied to a held-out set of bracket geometries and the predictions are compared against the ground-truth FEA-simulated mass values. ## Files - `/app/data/{train,val,public_test}/samples.pt` - each file is a dict with keys `features` (shape `[N, 2048, 3]`) and `labels` (shape `[N, 1]`), both float16 on disk. Load with `torch.load(path, weights_only=True)`. - `/app/task_config.json` - architecture sizes and training defaults. - `/app/feature_schema.json` - input / label spec. - `/app/candidate_model.py` - the fixed PointNet-style architecture. Keep it as is; your saved checkpoint has to load back into this same architecture. - `/app/dataset.py` - a minimal loader for `samples.pt`. - `/app/train.py` - a small starter training loop you can extend or replace. ## What you must produce Write your trained checkpoint to `/app/outputs/model.pt` as a dict with the following required keys: - `state_dict` - your model's `state_dict()` mapping. - `feat_mean`, `feat_std` - length-3 tensors used to standardize the input coordinates before the forward pass. The same normalization is applied at inference time, so these are required. - `label_mean`, `label_std` - length-1 tensors used to standardize the target during training and de-standardize the model output at inference. The model's output is mapped back to physical units using these, so they are required too. The model must consume a `[B, 2048, 3]` tensor and produce a `[B, 1]` output. ## What you are optimizing Your predictions are measured against the true FEA-simulated mass labels with three error numbers, all lower is better: the relative L2 over the full set (the primary one), the mean absolute error, and the worst-case per-sample absolute error. A strong model needs to do well on all three, so do not trade one off against the others.
Let me start by exploring the environment to understand what's available.
I'll start by exploring the environment to understand the task, the data, and the provided code.
bash
cd /app && ls -la && echo "---- CONFIG ----" && cat task_config.json && echo "---- SCHEMA ----" && cat feature_schema.json
output
total 14
drwxrws--- 3 h2tagent h2tagent  138 Jun 20 18:13 .
drwxr-xr-x 1 root     root     4096 Jun 20 21:39 ..
-rw-rw---- 1 h2tagent h2tagent 1639 Jun 20 18:10 candidate_model.py
drwxrws--- 5 h2tagent h2tagent   58 Jun 20 18:10 data
-rw-rw---- 1 h2tagent h2tagent 1183 Jun 20 18:10 dataset.py
-rw-rw---- 1 h2tagent h2tagent  499 Jun 20 18:10 feature_schema.json
-rw-rw---- 1 h2tagent h2tagent  581 Jun 20 18:10 task_config.json
-rw-rw---- 1 h2tagent h2tagent 3117 Jun 20 18:10 train.py
---- CONFIG ----
{
  "task_id": "simjeb-bracket-fea-mass-prediction-real",
  "dataset_name": "simjeb-bracket-fea-mass-pilot",
  "n_points": 2048,
  "point_dim": 3,
  "label_dim": 1,
  "label_per_point": false,
  "seed": 20260518,
  "model": {
    "feature_width": 64,
    "global_width": 256,
    "head_width": 128
  },
  "training": {
    "epochs": 1,
    "batch_size": 16,
    "lr": 0.001,
    "weight_decay": 0.0001,
    "loss": "MSE"
  },
  "evaluation": {
    "primary_metric": "mass_rel_l2_score",
    "secondary_metrics": [
      "mass_mae_score",
      "mass_max_abs_err_score"
    ]
  }
}
---- SCHEMA ----
{
  "version": "simjeb-bracket-fea-mass-pilot-v1",
  "input": {
    "type": "point_cloud",
    "n_points": 2048,
    "dim": 3,
    "coord_range": [
      -0.5,
      0.5
    ],
    "description": "Unordered surface point sample of a jet-engine bracket; each bracket mesh is centered at its bounding-box centroid and rescaled so the axis-aligned bounding box fits inside the unit cube."
  },
  "label": {
    "type": "scalar",
    "dim": 1,
    "description": "FEA-derived bracket mass in kg."
  }
}
Let me look at the provided code files.
Let me examine the provided model architecture, dataset loader, and training script.
bash
cd /app && echo "==== candidate_model.py ====" && cat candidate_model.py && echo "==== dataset.py ====" && cat dataset.py && echo "==== train.py ====" && cat train.py
output
==== candidate_model.py ====
from __future__ import annotations

from typing import Mapping

import torch
import torch.nn as nn


class SharedPointMLP(nn.Module):
    def __init__(self, in_dim: int, widths: tuple[int, ...]) -> None:
        super().__init__()
        layers: list[nn.Module] = []
        prev = in_dim
        for w in widths:
            layers.append(nn.Conv1d(prev, w, kernel_size=1))
            layers.append(nn.BatchNorm1d(w))
            layers.append(nn.GELU())
            prev = w
        self.net = nn.Sequential(*layers)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)


class PointNetRegressor(nn.Module):
    def __init__(self, feature_width: int, global_width: int, head_width: int) -> None:
        super().__init__()
        self.feature = SharedPointMLP(3, (feature_width, feature_width, feature_width))
        self.lift = SharedPointMLP(feature_width, (feature_width * 2, global_width))
        self.head = nn.Sequential(
            nn.Linear(global_width, head_width),
            nn.GELU(),
            nn.Linear(head_width, head_width // 2),
            nn.GELU(),
            nn.Linear(head_width // 2, 1),
        )

    def forward(self, points: torch.Tensor) -> torch.Tensor:
        x = points.transpose(1, 2).contiguous()
        x = self.feature(x)
        x = self.lift(x)
        x = x.max(dim=2).values
        return self.head(x)


def build_model(config: Mapping) -> PointNetRegressor:
    m = config["model"]
    return PointNetRegressor(
        feature_width=int(m["feature_width"]),
        global_width=int(m["global_width"]),
        head_width=int(m["head_width"]),
    )
==== dataset.py ====
from __future__ import annotations

from pathlib import Path
from typing import Iterable

import torch
from torch.utils.data import Dataset


class SampleStore(Dataset):
    def __init__(self, split_dir: str | Path) -> None:
        self.dir = Path(split_dir)
        payload = torch.load(self.dir / "samples.pt", map_location="cpu", weights_only=True)
        self.features: torch.Tensor = payload["features"].float()
        self.labels: torch.Tensor = payload["labels"].float()
        if self.features.shape[0] != self.labels.shape[0]:
            raise ValueError("features and labels must have the same number of rows")

    def __len__(self) -> int:
        return self.features.shape[0]

    def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
        return self.features[idx], self.labels[idx]


def iter_batches(
    x: torch.Tensor,
    y: torch.Tensor,
    batch_size: int,
    shuffle: bool = True,
) -> Iterable[tuple[torch.Tensor, torch.Tensor]]:
    n = x.shape[0]
    idx = torch.randperm(n) if shuffle else torch.arange(n)
    for start in range(0, n, batch_size):
        chunk = idx[start : start + batch_size]
        yield x[chunk], y[chunk]
==== train.py ====
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

import torch
import torch.nn.functional as F

APP_DIR = Path(os.environ.get("APP_DIR", "/app"))
sys.path.insert(0, str(APP_DIR))

from candidate_model import build_model  # noqa: E402
from dataset import SampleStore, iter_batches  # noqa: E402


def _device() -> torch.device:
    if torch.cuda.is_available():
        return torch.device("cuda")
    if torch.backends.mps.is_available():
        return torch.device("mps")
    return torch.device("cpu")


def _load_config() -> dict:
    with (APP_DIR / "task_config.json").open() as fh:
        return json.load(fh)


def main() -> None:
    cfg = _load_config()
    device = _device()
    torch.manual_seed(int(cfg["seed"]))

    train = SampleStore(APP_DIR / "data" / "train")
    val = SampleStore(APP_DIR / "data" / "val")

    feat_mean = train.features.mean(dim=(0, 1), keepdim=True)
    feat_std = train.features.std(dim=(0, 1), keepdim=True).clamp_min(1e-6)
    label_mean = train.labels.mean(dim=(0,), keepdim=True)
    label_std = train.labels.std(dim=
…[truncated]
Now let me look at the data itself. Let me understand the dataset sizes and statistics.
Now let me inspect the data itself - sizes, statistics, and label distributions.
bash
cd /app && ls -la data && echo "---" && for s in train val public_test; do echo "== $s =="; ls -la data/$s; done
output
total 3
drwxrws--- 5 h2tagent h2tagent  58 Jun 20 18:10 .
drwxrws--- 3 h2tagent h2tagent 138 Jun 20 18:13 ..
drwxrws--- 2 h2tagent h2tagent  55 Jun 20 18:10 public_test
drwxrws--- 2 h2tagent h2tagent  55 Jun 20 18:10 train
drwxrws--- 2 h2tagent h2tagent  55 Jun 20 18:10 val
---
== train ==
total 1210
drwxrws--- 2 h2tagent h2tagent      55 Jun 20 18:10 .
drwxrws--- 5 h2tagent h2tagent      58 Jun 20 18:10 ..
-rw-rw---- 1 h2tagent h2tagent    6483 Jun 20 18:10 manifest.jsonl
-rw-rw---- 1 h2tagent h2tagent 1230821 Jun 20 18:10 samples.pt
== val ==
total 389
drwxrws--- 2 h2tagent h2tagent     55 Jun 20 18:10 .
drwxrws--- 5 h2tagent h2tagent     58 Jun 20 18:10 ..
-rw-rw---- 1 h2tagent h2tagent   2011 Jun 20 18:10 manifest.jsonl
-rw-rw---- 1 h2tagent h2tagent 395109 Jun 20 18:10 samples.pt
== public_test ==
total 197
drwxrws--- 2 h2tagent h2tagent     55 Jun 20 18:10 .
drwxrws--- 5 h2tagent h2tagent     58 Jun 20 18:10 ..
-rw-rw---- 1 h2tagent h2tagent   1134 Jun 20 18:10 manifest.jsonl
-rw-rw---- 1 h2tagent h2tagent 198437 Jun 20 18:10 samples.pt
bash
cd /app && head -n 3 data/train/manifest.jsonl && echo "..." && wc -l data/train/manifest.jsonl data/val/manifest.jsonl data/public_test/manifest.jsonl
output
{"id": "train_0000", "source_bracket_id": 214, "n_points": 2048}
{"id": "train_0001", "source_bracket_id": 538, "n_points": 2048}
{"id": "train_0002", "source_bracket_id": 546, "n_points": 2048}
...
 100 data/train/manifest.jsonl
  32 data/val/manifest.jsonl
  16 data/public_test/manifest.jsonl
 148 total
So we have: - train: 100 samples - val: 32 samples - public_test: 16 samples This is a small dataset. Let me look at the actual data statistics. Let me load them and check. The manifest has `source_bracket_id`. Let me check if there's overlap between train/val/test source brackets , this could be important for understanding generalization. Let me examine.
Let me check the environment (GPU, torch version) and then load the data to examine statistics.
bash
cd /app && python -c "import torch, sys; print('torch', torch.__version__); print('cuda', torch.cuda.is_available()); print('py', sys.version)" && nproc && free -h 2>/dev/null | head -2
output
torch 2.5.1+cu124
cuda True
py 3.11.10 | packaged by conda-forge | (main, Oct 16 2024, 01:27:36) [GCC 13.3.0]
3
               total        used        free      shared  buff/cache   available
Mem:            30Gi        64Mi        29Gi          0B        29Mi        29Gi
bash
cd /app && nvidia-smi --query-gpu=name,memory.total,memory.used --format=csv 2>/dev/null || echo "no nvidia-smi"
output
name, memory.total [MiB], memory.used [MiB]
NVIDIA H100 80GB HBM3, 81559 MiB, 12690 MiB
We have an H100 GPU. Now let me load the data and examine its statistics carefully.
bash
cd /app && python -c "
import torch
for s in ['train','val','public_test']:
    d = torch.load(f'data/{s}/samples.pt', weights_only=True)
    f = d['features']; l = d['labels']
    print(f'== {s} ==')
    print('  features', f.shape, f.dtype, 'labels', l.shape, l.dtype)
    ff = f.float(); ll = l.float()
    print('  coord min/max', ff.min().item(), ff.max().item())
    print('  per-axis min', ff.amin(dim=(0,1)).tolist())
    print('  per-axis max', ff.amax(dim=(0,1)).tolist())
    print('  label min/max/mean/std', ll.min().item(), ll.max().item(), ll.mean().item(), ll.std().item())
"
output
== train ==
  features torch.Size([100, 2048, 3]) torch.float16 labels torch.Size([100, 1]) torch.float16
  coord min/max -0.5 0.5
  per-axis min [-0.320556640625, -0.5, -0.2279052734375]
  per-axis max [0.322998046875, 0.5, 0.22705078125]
  label min/max/mean/std 0.207763671875 2.21875 0.7475781440734863 0.3894648551940918
== val ==
  features torch.Size([32, 2048, 3]) torch.float16 labels torch.Size([32, 1]) torch.float16
  coord min/max -0.5 0.5
  per-axis min [-0.3095703125, -0.5, -0.2279052734375]
  per-axis max [0.31005859375, 0.5, 0.2275390625]
  label min/max/mean/std 0.293701171875 2.072265625 0.7621994018554688 0.41264674067497253
== public_test ==
  features torch.Size([16, 2048, 3]) torch.float16 labels torch.Size([16, 1]) torch.float16
  coord min/max -0.5 0.5
  per-axis min [-0.309326171875, -0.5, -0.1865234375]
  per-axis max [0.309326171875, 0.5, 0.1864013671875]
  label min/max/mean/std 0.1983642578125 1.44921875 0.7296066284179688 0.327779084444046
Let me understand the data better. We have: - 100 train samples - 32 val samples - 16 public_test samples Labels (mass in kg) range roughly 0.2 to 2.2. The bounding box Y axis is the longest (fits -0.5 to 0.5), X is about -0.32 to 0.32, Z about -0.23 to 0.23. This is the SimJEB dataset (Simulated Jet Engine Bracket). There are ~381 brackets in the full SimJEB dataset. Here we have source_bracket_ids. Let me check the overlap between splits - whether the same bracket appears in multiple splits (augmentation via different point samples), which matters a lot. Let me examine the source bracket ids.
Let me check the `source_bracket_id` overlap between splits, which matters a lot for how I should validate.
bash
cd /app && python -c "
import json
def ids(s):
    out=[]
    with open(f'data/{s}/manifest.jsonl') as fh:
        for line in fh:
            out.append(json.loads(line)['source_bracket_id'])
    return out
tr=ids('train'); va=ids('val'); te=ids('public_test')
print('train n=',len(tr),'unique=',len(set(tr)))
print('val   n=',len(va),'unique=',len(set(va)))
print('test  n=',len(te),'unique=',len(set(te)))
print('train∩val', sorted(set(tr)&set(va)))
print('train∩test', sorted(set(tr)&set(te)))
print('val∩test', sorted(set(va)&set(te)))
print('all unique source ids:', len(set(tr)|set(va)|set(te)))
print('train ids sorted:', sorted(set(tr)))
"
output
train n= 100 unique= 100
val   n= 32 unique= 32
test  n= 16 unique= 16
train∩val []
train∩test []
val∩test []
all unique source ids: 148
train ids sorted: [15, 16, 19, 22, 30, 33, 34, 35, 38, 39, 40, 50, 53, 55, 59, 64, 72, 101, 102, 106, 119, 131, 136, 140, 146, 150, 182, 192, 194, 195, 199, 204, 213, 214, 228, 233, 252, 256, 261, 262, 266, 275, 279, 290, 302, 322, 323, 348, 351, 356, 371, 380, 387, 395, 400, 413, 415, 417, 422, 424, 437, 439, 454, 456, 459, 474, 483, 489, 493, 495, 497, 498, 501, 506, 511, 512, 517, 519, 520, 523, 533, 538, 546, 551, 552, 559, 564, 565, 571, 572, 596, 610, 614, 616, 624, 626, 630, 631, 633, 634]
So each split has distinct brackets. No overlap. The model must generalize across brackets. 148 unique brackets total (out of ~381 in full SimJEB). Each bracket appears once (one point cloud sample). So no data augmentation from multiple samples of the same bracket in the dataset. Key facts: - Small dataset: 100 train, 32 val, 16 test. - Input: point cloud [2048, 3], pre-centered, bounding box fits in unit cube. - Output: mass (kg), range ~0.2 to 2.2. - Fixed architecture: PointNet-style regressor. Must keep architecture as-is. - We need to write checkpoint with state_dict, feat_mean, feat_std, label_mean, label_std. The challenge: mass prediction from point cloud. Mass is proportional to volume × density (assuming constant material density). The point cloud samples the surface. For a PointNet, predicting mass (which relates to volume) is tricky because PointNet uses max-pooling over per-point features, which captures extent/shape but volume estimation from surface points is non-trivial. Actually, mass = density × volume. For these brackets, presumably the same material (constant density), so mass ∝ volume. The volume is the enclosed volume of the solid bracket. From a surface point cloud, estimating volume is feasible if we could compute something like the convex hull or use the divergence theorem. But PointNet just does pointwise MLP + max pool. Let me think about what features help. The physical mass depends on the actual solid volume. The surface point cloud gives the shape. A PointNet can in principle learn a decent approximation. Important constraints: 1. The architecture is FIXED - I cannot change candidate_model.py (the checkpoint must load back into this same architecture). So I must use PointNetRegressor with feature_width=64, global_width=256, head_width=128. Wait, let me re-read: "candidate_model.py - the fixed PointNet-style architecture. Keep it as is; your saved checkpoint has to load back into this same architecture." So yes, architecture is fixed. I can't add features to input either? The model consumes [B, 2048, 3]. The forward does points.transpose then feature MLP starting at in_dim=3. So input must be 3D coordinates. But I CAN control the normalization (feat_mean, feat_std) and how I train. I can also control what preprocessing/augmentation I do at training time. But at inference, the grader applies: standardize input coords with feat_mean/feat_std, run forward, de-standardize output with label_mean/label_std. So the inference pipeline is fixed: (x - feat_mean)/feat_std -> model -> out*label_std + label_mean. Let me confirm the exact inference procedure the grader uses. I don't have the grader code, but based on the task description: - feat_mean, feat_std: length-3 tensors used to standardize input coordinates before forward pass. - label_mean, label_std: length-1 used to de-standardize output. So inference: pred_phys = model((x - feat_mean)/feat_std) * label_std + label_mean. I need to make sure my training matches this exactly. I'll train with the same normalization. Given the fixed architecture and fixed inference transform, my degrees of freedom: - Training procedure (epochs, lr, schedule, optimizer, loss). - Data augmentation during training. - Normalization constants (feat_mean/std, label_mean/std) - but these must be consistent between train and inference. I can choose them cleverly. - Regularization to avoid overfitting on 100 samples. Given only 100 training samples, overfitting is a big risk. I should use: - Data augmentation: random rotation? But wait , rotation would change the bounding box normalization. The brackets are axis-aligned bounding box normalized. Rotating would break that. Actually, mass is rotation-invariant (it's a physical scalar). But the normalization is axis-aligned-bbox-based, so the input representation is NOT rotation invariant , the same bracket rotated would be rescaled differently. Since test data is also bbox-normalized in the same way, I should be carefu …[truncated]
This is the SimJEB (Simulated Jet Engine Bracket) dataset. Key observations: small dataset (100 train / 32 val / 16 test), no bracket-ID overlap between splits, so the model must generalize across geometries. Let me do some EDA to understand the geometry→mass relationship, starting with the bounding-box normalization.
bash
cd /app && python -c "
import torch
for s in ['train','val','public_test']:
    d = torch.load(f'data/{s}/samples.pt', weights_only=True)
    f = d['features'].float()
    # per-sample bbox extents
    mn = f.amin(dim=1); mx = f.amax(dim=1)  # [N,3]
    ext = mx - mn  # [N,3]
    ctr = (mx+mn)/2
    print(f'== {s} ==  N={f.shape[0]}')
    print('  extent mean', ext.mean(0).tolist())
    print('  extent min ', ext.amin(0).tolist())
    print('  extent max ', ext.amax(0).tolist())
    print('  max-extent over axes per sample: min/max =', ext.max(1).values.min().item(), ext.max(1).values.max().item())
    print('  center mean', ctr.mean(0).tolist(), 'center absmax', ctr.abs().amax(0).tolist())
"
output
== train ==  N=100
  extent mean [0.5887036323547363, 0.9991015791893005, 0.34989869594573975]
  extent min  [0.502197265625, 0.9912109375, 0.2757568359375]
  extent max  [0.6435546875, 1.0, 0.4549560546875]
  max-extent over axes per sample: min/max = 0.9912109375 1.0
  center mean [0.00040649413131177425, -7.812499825377017e-05, -0.00028625488630495965] center absmax [0.0040283203125, 0.00341796875, 0.00299072265625]
== val ==  N=32
  extent mean [0.59246826171875, 0.9992523193359375, 0.35378265380859375]
  extent min  [0.574462890625, 0.996337890625, 0.3458251953125]
  extent max  [0.61962890625, 1.0, 0.4554443359375]
  max-extent over axes per sample: min/max = 0.996337890625 1.0
  center mean [0.00043487548828125, 0.0001068115234375, -0.000133514404296875] center absmax [0.003173828125, 0.0015869140625, 0.00054931640625]
== public_test ==  N=16
  extent mean [0.5922698974609375, 0.9989013671875, 0.3443603515625]
  extent min  [0.584716796875, 0.99560546875, 0.290283203125]
  extent max  [0.61865234375, 1.0, 0.3729248046875]
  max-extent over axes per sample: min/max = 0.99560546875 1.0
  center mean [0.00031280517578125, 1.52587890625e-05, -0.0002288818359375] center absmax [0.0013427734375, 0.0009765625, 0.00103759765625]
Confirmed: - Y extent is always ~1.0 (normalized so Y is the longest axis, scaled to 1). Centers ~0 (centered at bbox centroid). Good. - X extent ~0.5-0.64, Z extent ~0.28-0.45. So aspect ratios vary modestly. Since Y is always the max and =1, the normalization is uniform scaling by 1/(Y extent in physical units). So physical size = normalized × (physical Y length). We don't know physical Y length. But as discussed, SimJEB brackets share a fixed mounting envelope, so physical Y length is likely near-constant. Thus normalized shape → mass is well-defined. Now, mass ∝ physical volume = (scale)^3 × normalized_volume, where scale ≈ constant. So mass ≈ const × normalized_volume (the volume enclosed by the normalized surface). So if the network can estimate the enclosed volume of the normalized shape, it gets mass up to the near-constant scale factor and density. But PointNet with max-pooling is not great at volume estimation. Still, it can learn correlations. Let me check how well simple features predict mass, to set a baseline and expectation. Let me compute correlation between mass and: - X extent, Z extent, X*Z (cross-section area proxy), X*Z*1 (bbox volume proxy). - Mean pairwise distance / spread. - Number/density of points in certain regions. Actually a great proxy for "how bulky" the bracket is: the average distance of surface points from the center, or the spread. Thin brackets have points concentrated; bulky have spread. Hmm not obviously. Let me just compute some correlations.
Confirmed: Y is always normalized to 1.0 (the long axis), centered at origin. So normalization is uniform scaling by 1/(physical Y-length). Since SimJEB brackets share a fixed mounting envelope, normalized shape should predict mass well. Let me check how well simple geometric features correlate with mass, to set expectations and a baseline.
bash
cd /app && python -c "
import torch, numpy as np
d = torch.load('data/train/samples.pt', weights_only=True)
f = d['features'].float(); y = d['labels'].float().squeeze(1)
N = f.shape[0]
mn = f.amin(dim=1); mx = f.amax(dim=1); ext = mx-mn
feats = {}
feats['ext_x']=ext[:,0]; feats['ext_z']=ext[:,2]
feats['bbox_vol']=ext[:,0]*ext[:,1]*ext[:,2]
feats['xz_area']=ext[:,0]*ext[:,2]
# radial spread
r = f.norm(dim=2)  # [N,2048]
feats['mean_r']=r.mean(1); feats['std_r']=r.std(1)
# per-axis std of points
feats['std_x']=f[:,:,0].std(1); feats['std_y']=f[:,:,1].std(1); feats['std_z']=f[:,:,2].std(1)
# mean abs coord
feats['mabs_x']=f[:,:,0].abs().mean(1); feats['mabs_z']=f[:,:,2].abs().mean(1)
# proxy 'compactness': average nearest-neighbor distance (subsample for speed)
for k,v in feats.items():
    v=v.numpy(); yy=y.numpy()
    c=np.corrcoef(v,yy)[0,1]
    print(f'{k:10s} corr={c:+.3f}')
print('label mean/std', y.mean().item(), y.std().item())
"
output
ext_x      corr=+0.234
ext_z      corr=+0.177
bbox_vol   corr=+0.253
xz_area    corr=+0.249
mean_r     corr=+0.394
std_r      corr=-0.173
std_x      corr=+0.635
std_y      corr=+0.149
std_z      corr=+0.250
mabs_x     corr=+0.568
mabs_z     corr=-0.078
label mean/std 0.7475781440734863 0.3894648551940918
Interesting. std_x (spread of points along X) has correlation 0.635 with mass. mabs_x 0.568. mean_r 0.394. These are moderate. So simple features give moderate correlation but not great. The relationship is nonlinear / needs the network. Let me try a more powerful baseline: a quick k-NN or a gradient boosting on a richer set of handcrafted features, to understand the achievable rel-L2. Actually, the real task is to train the PointNet. But knowing the achievable error helps me gauge. Let me think about what rel-L2 a good model can achieve. Let me try: occupancy voxel grid features → could estimate volume. Let me compute a voxelization: divide the unit cube into voxels, mark voxels that contain surface points. The count of occupied voxels relates to surface area, not volume. To get volume, I'd need to fill the interior. Hard from surface points but possible with a voxel flood fill from outside. That's a strong handcrafted volume estimator. But for the network, it just learns. Actually, let me step back. My job: produce the best checkpoint for the fixed PointNet architecture. Let me focus on training that architecture well. Let me: 1. Build a robust training pipeline with: - Input normalization (feat_mean/std from train). - Label normalization (label_mean/std from train). - Augmentation: jitter + reflections (+ maybe tiny rotation/scaling). - Good optimizer (AdamW), cosine LR schedule, many epochs. - EMA of weights for the final checkpoint (loads into same arch). - Early stopping / model selection on val. 2. Evaluate with the actual metrics (rel-L2, MAE, max abs err) on val and public_test. Let me set up an evaluation harness that mimics the grader: pred_phys = model((x - feat_mean)/feat_std) * label_std + label_mean. Let me write a proper training script. But first, let me establish the baseline by running the provided train.py quickly (1 epoch) and evaluate, to see where we start. Actually the provided train.py uses 1 epoch. Let me instead write my own evaluation function and a baseline. Let me also think carefully about the metrics definitions to optimize correctly. "relative L2 over the full set" = sqrt(sum (pred-true)^2) / sqrt(sum true^2). MAE = mean |pred-true|. max abs err = max |pred-true|. Given mass mean ~0.73, std ~0.39. rel-L2 of a trivial mean predictor: predict mean → error std ~0.39 (on val maybe), ||true|| ~ sqrt(N)*0.83 (rms of true). Let me compute: rel-L2 of predicting constant mean = std-ish / rms. rms of true = sqrt(mean^2+std^2) ≈ sqrt(0.73^2+0.39^2)=sqrt(0.53+0.15)=sqrt(0.68)=0.83. Predicting mean gives residual rms ≈ std = 0.39 (if predict train mean on val, plus bias). rel-L2 ≈ 0.39/0.83 ≈ 0.47. So we need to beat 0.47 substantially. A good model might get rel-L2 ~0.1-0.2. Let me get a strong baseline reading by training the network properly and measuring. Let me write the improved training script now. Let me think about key design decisions for best performance: 1. Label transform: The inference de-standardizes linearly: out*label_std+label_mean. So I train model to predict (mass - label_mean)/label_std. I'll use train mean/std. Good. Could also consider robust scaling but linear std is fine. 2. Since mass is positive and the metric is relative L2 (not relative per-sample), absolute errors matter. Larger-mass samples contribute more to both numerator and denominator. Since it's aggregate rel-L2, minimizing MSE in physical units aligns with minimizing the numerator. So training with MSE on physical mass (or standardized, equivalent up to constant) is aligned with rel-L2 numerator. Good. But worst-case abs err suggests I also want to avoid big outliers → MSE already penalizes large errors. Could add a small Huber or keep MSE. Actually, to directly optimize rel-L2, MSE loss on standardized labels is essentially proportional to MSE on physical (since label_std constant). rel-L2^2 = sum(err^2)/sum(true^2). Minimizing sum(err^2) = minimizing MSE. So MSE is the right loss for r …[truncated]
Simple features only weakly correlate with mass (best ~0.63 for X-spread), so the mapping is genuinely nonlinear , the network has to learn it. Let me check whether the brackets have reflection symmetry, since mirror augmentations (which preserve both volume/mass and the bbox normalization) could be a powerful, physically-valid way to expand my 100 training samples.
bash
cd /app && python -c "
import torch
d = torch.load('data/train/samples.pt', weights_only=True)
f = d['features'].float()  # [N,2048,3]
N = f.shape[0]
def chamfer(a,b):
    # a,b: [P,3]  -> mean nearest neighbor both directions, subsample 512 for speed
    a=a[:512]; b=b[:512]
    d2 = (a[:,None,:]-b[None,:,:]).pow(2).sum(-1)  # [P,P]
    return d2.min(1).values.mean().sqrt().item()/2 + d2.min(0).values.mean().sqrt().item()/2
import random
idxs=list(range(N))
for ax,name in [(0,'X'),(1,'Y'),(2,'Z')]:
    vals=[]
    for i in idxs[:30]:
        c=f[i]
        m=c.clone(); m[:,ax]=-m[:,ax]
        vals.append(chamfer(c,m))
    t=torch.tensor(vals)
    print(f'reflect {name}: chamfer mean={t.mean():.4f} median={t.median():.4f} min={t.min():.4f} max={t.max():.4f}')
# compare to chamfer between DIFFERENT brackets (scale reference)
import itertools
vals=[]
for i in range(0,20,2):
    vals.append(chamfer(f[i],f[i+1]))
print('between different brackets chamfer mean=',torch.tensor(vals).mean().item())
"
output
reflect X: chamfer mean=0.0677 median=0.0542 min=0.0429 max=0.1132
reflect Y: chamfer mean=0.0314 median=0.0313 min=0.0257 max=0.0367
reflect Z: chamfer mean=0.1353 median=0.1346 min=0.0821 max=0.1755
between different brackets chamfer mean= 0.048194821923971176
Interesting. Reflection across Y has the smallest Chamfer (0.031), meaning brackets are fairly symmetric top-bottom along the long axis? That's surprising. Reflection across X: 0.068, across Z: 0.135 (least symmetric). Between different brackets: 0.048. So reflecting across Y gives chamfer 0.031 which is smaller than between-bracket distance (0.048), suggesting decent Y-symmetry. Reflect X (0.068) is a bit larger than between-bracket. Reflect Z (0.135) clearly asymmetric. Hmm, so the brackets are most symmetric about the Y-reflection and somewhat about X. Regardless, as I argued, reflection is a mass-preserving transformation producing a geometrically valid (mirror) shape. Even if the mirror isn't identical to the original, it's a valid bracket-like shape with the same mass and same bbox. Using it as augmentation teaches reflection-invariance of the mass function, which is physically correct. But there's a risk: if test shapes are always in a canonical chirality and the learned features rely on that, mirrored training could add noise. But since the target (mass=volume) is truly reflection-invariant, enforcing invariance cannot bias the target; it only constrains the function to be invariant, which the true function is. The only downside is if the model capacity is wasted, but more likely it regularizes well. I'll test empirically on val. Actually, the cleanest argument: For ANY point cloud P with mass m, the reflected cloud R(P) also corresponds to a real solid with the same mass m. So (R(P), m) is a valid labeled training example from the true data-generating distribution of "bracket solids" (just not in the exact SimJEB catalog). Using it can only help learn the true invariant function, assuming the function we want is the physical mass. So reflection augmentation across all 3 axes is safe and increases data up to 8x. Let me now build the training pipeline and run experiments. I'll create a new script `solve.py` that: - Loads data onto GPU. - Computes normalization from train. - Defines augmentation. - Trains with AdamW + cosine schedule + warmup. - Uses EMA. - Evaluates rel-L2, MAE, maxabs on val (and public_test for reference) using the exact inference transform. - Saves best checkpoint. Let me first write an evaluation function module and a flexible training function so I can run ablations quickly. Given small data and H100, training is fast; I can do many epochs and several runs. Let me write the harness. Let me think about hyperparameters: - Batch size: with BN, I want stable stats. 100 train samples. With 8x reflection aug, effectively 800. Batch 32 or 64. Let me try 32. - Actually, BN at eval uses running stats. I want running stats to reflect the (normalized, non-augmented) test distribution. But training sees augmented (jittered/reflected) data. Jitter slightly changes stats but reflection doesn't change per-feature distribution (since data is symmetric around 0, reflection preserves marginal distribution). Jitter adds small variance. Running stats will be close to true. Fine. - Epochs: try 200-400 with cosine. Monitor val. - LR: AdamW 1e-3 with cosine to ~1e-5. weight_decay 1e-4 to 1e-2 (tune). - EMA decay 0.999 or 0.9995. Let me consider whether to select the model by val rel-L2 (early stopping) or use EMA final. Given only 32 val samples, val metric is noisy. Model selection on 32 samples risks overfitting to val. I'll use a combination: train with good regularization, use EMA, and pick epoch by val but also sanity check. Possibly average over a few seeds via weight averaging? Averaging weights of independently-initialized networks doesn't work well (different basins). SWA within one run works. EMA within one run works. Alternative robust approach: k-fold cross-validation over the 100 train (+32 val = 132 labeled) to pick hyperparameters, then train final model on all 132 (train+val) with chosen settings, and use public_test only as a final sanity check. Wait , can I train on val too? The val set i …[truncated]
Reflection is a mass-preserving, bbox-preserving transformation, so a mirrored cloud is a physically valid bracket with identical mass , this lets me legitimately expand my 100 samples up to 8×. The Y/X reflections are even close to true symmetries. I'll validate this empirically. Let me build a reusable training+evaluation harness that mimics the exact grader inference transform.
bash
mkdir -p /app/work && cat > /app/work/lib.py << 'PY'
from __future__ import annotations
import os, sys, json, math, copy
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F

APP = Path('/app')
sys.path.insert(0, str(APP))
from candidate_model import build_model

def load_cfg():
    with open(APP/'task_config.json') as fh:
        return json.load(fh)

def load_split(s):
    d = torch.load(APP/'data'/s/'samples.pt', weights_only=True)
    return d['features'].float(), d['labels'].float()

def compute_norm(feats, labels, isotropic=False):
    fm = feats.mean(dim=(0,1))
    if isotropic:
        fs = feats.std().repeat(3)
    else:
        fs = feats.std(dim=(0,1)).clamp_min(1e-6)
    lm = labels.mean(dim=0)
    ls = labels.std(dim=0).clamp_min(1e-6)
    return fm, fs, lm, ls

@torch.no_grad()
def predict(model, X, fm, fs, lm, ls, device, bs=256):
    # exact inference transform: ((x-fm)/fs) -> model -> *ls + lm
    model.eval()
    out=[]
    for i in range(0, X.shape[0], bs):
        xb = X[i:i+bs].to(device)
        xb = (xb - fm.to(device))/fs.to(device)
        p = model(xb)
        out.append(p.cpu())
    p = torch.cat(out,0)
    return p*ls + lm

def metrics(pred, true):
    pred=pred.reshape(-1).double(); true=true.reshape(-1).double()
    err = pred-true
    rel_l2 = (err.pow(2).sum().sqrt()/true.pow(2).sum().sqrt()).item()
    mae = err.abs().mean().item()
    maxabs = err.abs().max().item()
    return dict(rel_l2=rel_l2, mae=mae, maxabs=maxabs)

def augment(xb, jitter=0.01, reflect=(True,True,True), rot_deg=0.0, scale_jit=0.0, clip=0.03):
    # xb: [B,2048,3] on device
    B = xb.shape[0]; dev=xb.device
    if any(reflect):
        signs = torch.ones(B,1,3, device=dev)
        for a,on in enumerate(reflect):
            if on:
                flip = (torch.rand(B,1,device=dev) < 0.5).float()*(-2)+1  # +1 or -1
                signs[:,:,a] = flip.squeeze(1)
        xb = xb*signs
    if rot_deg>0:
        # small rotation about Y axis (vertical long axis) only, to keep envelope-ish
        ang = (torch.rand(B,device=dev)*2-1)*math.radians(rot_deg)
        c=torch.cos(ang); s=torch.sin(ang)
        R = torch.zeros(B,3,3, device=dev)
        R[:,0,0]=c; R[:,0,2]=s; R[:,2,0]=-s; R[:,2,2]=c; R[:,1,1]=1
        xb = torch.bmm(xb, R.transpose(1,2))
    if scale_jit>0:
        sc = 1+ (torch.rand(B,1,3,device=dev)*2-1)*scale_jit
        xb = xb*sc
    if jitter>0:
        n = torch.randn_like(xb)*jitter
        if clip>0: n = n.clamp(-clip, clip)
        xb = xb + n
    return xb

class EMA:
    def __init__(self, model, decay=0.999):
        self.decay=decay
        self.shadow={k:v.detach().clone() for k,v in model.state_dict().items()}
    def update(self, model):
        for k,v in model.state_dict().items():
            s=self.shadow[k]
            if v.dtype.is_floating_point:
                s.mul_(self.decay).add_(v.detach(), alpha=1-self.decay)
            else:
                s.copy_(v)
    def copy_to(self, model):
        model.load_state_dict(self.shadow, strict=True)
PY
echo "wrote lib.py"
output
wrote lib.py
Now let me write a training function and run a baseline experiment. Let me create a train routine that takes hyperparameters and returns val metrics, with EMA and model selection. Note on EMA + BatchNorm: EMA of BN running_mean/running_var , I copy non-float buffers directly but running stats are floats so they get EMA'd too. Actually BN running_mean/var are floats and num_batches_tracked is int (copied directly). EMA'ing running stats is okay-ish but they already are smoothed. Alternatively, after copying EMA weights, I should recompute BN running stats by a forward pass over training data in train mode (BN update) , or just rely on EMA'd stats. A cleaner approach: after setting EMA weights, do a few forward passes over training data in eval-BN-update mode to recalibrate BN. But simplest: EMA the running stats too (they're floats). Let me just EMA everything float. Since running stats change slowly, EMA of them is fine. Actually there's subtlety: the EMA shadow for running_mean/var uses decay too, so they lag. But they should be fine for eval. Let me also implement a "BN recalibration" option: after loading final weights, set BN to train mode, run through training data a few times to update running stats with momentum, then eval. This ensures BN stats match the weights. I'll add that as an option for the final model. Let me write the train function.
Now the training function with augmentation, EMA, cosine schedule, and model selection:
bash
cat > /app/work/trainer.py << 'PY'
from __future__ import annotations
import math, copy, time
import torch, torch.nn.functional as F
from lib import build_model, predict, metrics, augment, EMA

def train_model(cfg, Xtr, Ytr, fm, fs, lm, ls, device,
                Xval=None, Yval=None, epochs=300, bs=32, lr=1e-3, wd=1e-4,
                jitter=0.01, reflect=(True,True,True), rot_deg=0.0, scale_jit=0.0,
                ema_decay=0.999, warmup=10, seed=0, loss='mse', huber_beta=0.1,
                select='ema', verbose=False, sched='cosine', min_lr_ratio=0.01):
    torch.manual_seed(seed); 
    model = build_model(cfg).to(device)
    opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wd)
    Xtr_d = Xtr.to(device); Ytr_d = ((Ytr-lm)/ls).to(device)
    N = Xtr_d.shape[0]
    ema = EMA(model, ema_decay)
    steps_per = max(1, math.ceil(N/bs))
    total = epochs*steps_per
    def lr_at(step):
        if step < warmup*steps_per:
            return lr*(step+1)/(warmup*steps_per)
        if sched=='cosine':
            prog=(step-warmup*steps_per)/max(1,(total-warmup*steps_per))
            return lr*(min_lr_ratio + (1-min_lr_ratio)*0.5*(1+math.cos(math.pi*prog)))
        return lr
    best=None; best_state=None; step=0
    for ep in range(epochs):
        model.train()
        perm = torch.randperm(N, device=device)
        for i in range(0, N, bs):
            idx = perm[i:i+bs]
            if idx.numel()<2:  # avoid BN on batch size 1
                continue
            xb = Xtr_d[idx]; yb = Ytr_d[idx]
            xb = augment(xb, jitter=jitter, reflect=reflect, rot_deg=rot_deg, scale_jit=scale_jit)
            xb = (xb - fm.to(device))/fs.to(device)
            for g in opt.param_groups: g['lr']=lr_at(step)
            opt.zero_grad(set_to_none=True)
            pred = model(xb)
            if loss=='mse':
                l = F.mse_loss(pred, yb)
            elif loss=='huber':
                l = F.smooth_l1_loss(pred, yb, beta=huber_beta)
            l.backward()
            opt.step(); ema.update(model); step+=1
        if Xval is not None and (ep+1)%5==0:
            # evaluate EMA
            cur = copy.deepcopy(model.state_dict())
            if select=='ema': ema.copy_to(model)
            m = metrics(predict(model, Xval, fm,fs,lm,ls, device), Yval)
            model.load_state_dict(cur)
            score = m['rel_l2']
            if best is None or score<best:
                best=score
                best_state = {k:v.detach().cpu().clone() for k,v in (ema.shadow if select=='ema' else model.state_dict()).items()}
            if verbose and ((ep+1)%25==0):
                print(f'  ep{ep+1}: val rel_l2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} max={m["maxabs"]:.4f} (best={best:.4f})')
    # final: if no val, use ema end
    if best_state is None:
        if select=='ema': ema.copy_to(model)
        best_state={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
    return model, best_state, best
PY
echo done
output
done
bash
cd /app/work && python -c "
import torch, time
from lib import *
from trainer import train_model
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val'); Xte,Yte=load_split('public_test')
fm,fs,lm,ls=compute_norm(Xtr,Ytr)
print('feat_mean',fm.tolist(),'feat_std',fs.tolist(),'label_mean',lm.tolist(),'label_std',ls.tolist())
# baseline: predict train mean
import copy
pm = torch.full_like(Yval, lm.item())
print('predict-train-mean val:', metrics(pm,Yval))
t=time.time()
model,state,best=train_model(cfg,Xtr,Ytr,fm,fs,lm,ls,device,Xval,Yval,
   epochs=200,bs=32,lr=1e-3,wd=1e-4,jitter=0.01,reflect=(True,True,True),
   ema_decay=0.999,warmup=10,seed=0,select='ema',verbose=True)
print('time',time.time()-t,'best val rel_l2',best)
model.load_state_dict(state)
print('VAL ', metrics(predict(model,Xval,fm,fs,lm,ls,device),Yval))
print('TEST', metrics(predict(model,Xte,fm,fs,lm,ls,device),Yte))
"
output
feat_mean [-0.013504491187632084, 0.007462748326361179, -0.0709712877869606] feat_std [0.14211857318878174, 0.2701786458492279, 0.0871192067861557] label_mean [0.7475781440734863] label_std [0.3894648551940918]
predict-train-mean val: {'rel_l2': 0.4705697519398283, 'mae': 0.30238068103790283, 'maxabs': 1.3246874809265137}
[stderr]
Traceback (most recent call last):
  File "<string>", line 14, in <module>
  File "/app/work/trainer.py", line 35, in train_model
    xb = augment(xb, jitter=jitter, reflect=reflect, rot_deg=rot_deg, scale_jit=scale_jit)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/work/lib.py", line 59, in augment
    signs[:,:,a] = flip.squeeze(1)
    ~~~~~^^^^^^^
RuntimeError: The expanded size of the tensor (1) must match the existing size (32) at non-singleton dimension 1.  Target sizes: [32, 1].  Tensor sizes: [32]
Bug in augment. signs has shape [B,1,3]. signs[:,:,a] has shape [B,1]. flip.squeeze(1) has shape [B]. Need to assign flip (shape [B,1]) directly. Let me fix: `signs[:,:,a] = flip` where flip is [B,1]. Actually signs[:,:,a] is [B,1], flip is [B,1]. Good. Remove squeeze. Let me fix.
Small shape bug in the reflection augmentation. Let me fix it.
bash
cd /app/work && sed -i 's/                signs\[:,:,a\] = flip.squeeze(1)/                signs[:,:,a] = flip/' lib.py && sed -n '52,62p' lib.py
output
# xb: [B,2048,3] on device
    B = xb.shape[0]; dev=xb.device
    if any(reflect):
        signs = torch.ones(B,1,3, device=dev)
        for a,on in enumerate(reflect):
            if on:
                flip = (torch.rand(B,1,device=dev) < 0.5).float()*(-2)+1  # +1 or -1
                signs[:,:,a] = flip
        xb = xb*signs
    if rot_deg>0:
        # small rotation about Y axis (vertical long axis) only, to keep envelope-ish
bash
cd /app/work && python -c "
import torch, time
from lib import *
from trainer import train_model
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val'); Xte,Yte=load_split('public_test')
fm,fs,lm,ls=compute_norm(Xtr,Ytr)
t=time.time()
model,state,best=train_model(cfg,Xtr,Ytr,fm,fs,lm,ls,device,Xval,Yval,
   epochs=200,bs=32,lr=1e-3,wd=1e-4,jitter=0.01,reflect=(True,True,True),
   ema_decay=0.999,warmup=10,seed=0,select='ema',verbose=True)
print('time %.1fs'%(time.time()-t),'best val rel_l2',round(best,4))
model.load_state_dict(state)
print('VAL ', {k:round(v,4) for k,v in metrics(predict(model,Xval,fm,fs,lm,ls,device),Yval).items()})
print('TEST', {k:round(v,4) for k,v in metrics(predict(model,Xte,fm,fs,lm,ls,device),Yte).items()})
"
output
ep25: val rel_l2=0.4708 mae=0.3014 max=1.3288 (best=0.4708)
  ep50: val rel_l2=0.4708 mae=0.3011 max=1.3299 (best=0.4708)
  ep75: val rel_l2=0.4709 mae=0.3007 max=1.3313 (best=0.4708)
  ep100: val rel_l2=0.4710 mae=0.3003 max=1.3330 (best=0.4708)
  ep125: val rel_l2=0.4712 mae=0.2998 max=1.3350 (best=0.4708)
  ep150: val rel_l2=0.4713 mae=0.2993 max=1.3372 (best=0.4708)
  ep175: val rel_l2=0.4715 mae=0.2987 max=1.3396 (best=0.4708)
  ep200: val rel_l2=0.4718 mae=0.2980 max=1.3424 (best=0.4708)
time 20.5s best val rel_l2 0.4708
VAL  {'rel_l2': 0.4708, 'mae': 0.3014, 'maxabs': 1.3285}
TEST {'rel_l2': 0.3993, 'mae': 0.2602, 'maxabs': 0.7054}
The EMA model is basically predicting the mean (rel_l2 ~0.47, same as mean predictor). This suggests the EMA decay 0.999 with only ~200 epochs * 4 steps = 800 steps means the EMA barely moved from init? No , EMA of 0.999 over 800 steps: 0.999^800 ≈ 0.45, so 55% of the way. Hmm. Actually the issue might be that the EMA weights are lagging heavily and the model hasn't learned. Or the model itself isn't learning (output ~constant). Wait, val rel_l2 = 0.4708 which equals the mean predictor. The EMA model essentially outputs a constant. Let me check the NON-EMA model performance, and check training loss is decreasing. Let me debug by evaluating the raw model (not EMA) and printing train loss. The EMA with decay 0.999 and 800 steps is too slow to catch up if the model converges slowly. But more likely, with BN and the EMA including BN running stats, something's off. Let me evaluate the raw (non-EMA) model on val to see if it learned. Let me add diagnostics.
The EMA model is stuck near the mean predictor. Let me diagnose by checking the raw (non-EMA) model and the training loss trajectory.
bash
cd /app/work && python -c "
import torch, time, math, copy
import torch.nn.functional as F
from lib import *
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
fm,fs,lm,ls=compute_norm(Xtr,Ytr)
torch.manual_seed(0)
model=build_model(cfg).to(device)
opt=torch.optim.AdamW(model.parameters(),lr=1e-3,weight_decay=1e-4)
Xtr_d=Xtr.to(device); Ytr_d=((Ytr-lm)/ls).to(device); N=Xtr_d.shape[0]
fmd=fm.to(device); fsd=fs.to(device)
for ep in range(150):
    model.train(); perm=torch.randperm(N,device=device); tot=0;nb=0
    for i in range(0,N,32):
        idx=perm[i:i+32]
        xb=Xtr_d[idx]; yb=Ytr_d[idx]
        xb=augment(xb,jitter=0.01,reflect=(True,True,True))
        xb=(xb-fmd)/fsd
        opt.zero_grad(set_to_none=True)
        p=model(xb); l=F.mse_loss(p,yb); l.backward(); opt.step()
        tot+=l.item(); nb+=1
    if (ep+1)%25==0:
        model.eval()
        with torch.no_grad():
            vp=predict(model,Xval,fm,fs,lm,ls,device)
        print(f'ep{ep+1} trainloss={tot/nb:.4f} raw-val={metrics(vp,Yval)[\"rel_l2\"]:.4f}')
"
output
ep25 trainloss=0.9897 raw-val=0.3936
ep50 trainloss=0.5915 raw-val=0.3622
ep75 trainloss=0.7812 raw-val=0.3320
ep100 trainloss=0.5395 raw-val=0.3326
ep125 trainloss=0.4857 raw-val=0.3545
ep150 trainloss=0.5313 raw-val=0.3436
So the RAW model learns (val rel_l2 drops to ~0.33). The EMA with decay 0.999 was the problem , it lagged too much and included BN running stats badly. The issue: my EMA copies ALL state_dict entries including BN running_mean/running_var AND num_batches_tracked. With decay 0.999, the EMA of running stats lags. But more importantly, the raw model reaches 0.33 while EMA gave 0.47 (basically mean). The problem: EMA decay 0.999 is too high for 800 steps , the shadow is still ~halfway to init (init outputs ~mean because of final layer). Actually the head's final linear initialized small → output near 0 → predicts label_mean. EMA heavily weights early near-constant model. So EMA underfits. I should lower EMA decay or use more steps, or update EMA per-epoch. Train loss ~0.5-0.9 (standardized MSE; 1.0 = variance). val rel_l2 0.33 is better than mean (0.47) but not great. trainloss around 0.5 means standardized MSE 0.5 → explaining half the variance. We want much lower. Let me improve. Key issues: 1. EMA decay too high → use 0.99 or lower, or skip EMA, or use SWA averaging only over late epochs. 2. The model may be underfitting , needs more epochs / better LR schedule / more capacity (can't change capacity). 3. val rel_l2 ~0.33 still high. Need better. Let me reconsider. train loss 0.5 (standardized) → the model isn't even fitting train that well. This is a capacity/optimization issue OR the augmentation (jitter 0.01, reflections) makes train harder. Let me check train fit without augmentation, and whether the model can overfit train (sanity check capacity). If it can drive train loss near 0, then capacity is fine and I need regularization tuning. If it can't, capacity/optimization is the bottleneck. Let me test: no augmentation, train only, see min train loss and val. Also the relationship might just be hard for this architecture. Let me see how low train loss can go (overfit test).
The raw model learns (val rel-L2 ≈ 0.33), but my EMA decay (0.999) was far too high for only ~800 steps, leaving the shadow stuck near the constant-output initialization. Before tuning EMA, let me check the architecture's capacity: can it overfit the training set? That tells me whether the bottleneck is optimization or regularization.
bash
cd /app/work && python -c "
import torch, math
import torch.nn.functional as F
from lib import *
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train')
fm,fs,lm,ls=compute_norm(Xtr,Ytr)
torch.manual_seed(0)
model=build_model(cfg).to(device)
opt=torch.optim.AdamW(model.parameters(),lr=1e-3,weight_decay=0.0)
Xtr_d=Xtr.to(device); Ytr_d=((Ytr-lm)/ls).to(device); N=Xtr_d.shape[0]
fmd=fm.to(device); fsd=fs.to(device)
Xn=(Xtr_d-fmd)/fsd
for ep in range(400):
    model.train(); perm=torch.randperm(N,device=device)
    for i in range(0,N,32):
        idx=perm[i:i+32]
        opt.zero_grad(set_to_none=True)
        p=model(Xn[idx]); l=F.mse_loss(p,Ytr_d[idx]); l.backward(); opt.step()
    if (ep+1)%50==0:
        model.eval()
        with torch.no_grad():
            tp=predict(model,Xtr,fm,fs,lm,ls,device)
        print(f'ep{ep+1} train rel_l2={metrics(tp,Ytr)[\"rel_l2\"]:.4f} mae={metrics(tp,Ytr)[\"mae\"]:.4f}')
"
output
ep50 train rel_l2=0.2878 mae=0.1753
ep100 train rel_l2=0.2105 mae=0.1339
ep150 train rel_l2=0.1818 mae=0.1249
ep200 train rel_l2=0.1365 mae=0.0900
ep250 train rel_l2=0.1430 mae=0.0912
ep300 train rel_l2=0.1234 mae=0.0844
ep350 train rel_l2=0.1579 mae=0.0985
ep400 train rel_l2=0.1223 mae=0.0843
Even without weight decay and no augmentation, the model only reaches train rel_l2 ~0.12 (not near 0). So the architecture has limited capacity to fit this mapping , it struggles to memorize even 100 training samples. This is the max-pooling bottleneck: global max-pool of 256 features → limited info. So the bottleneck is partly capacity/optimization, not just overfitting. This means I should focus on getting the most out of this architecture. train rel_l2 0.12 is the floor-ish with this setup. With BN in eval mode, evaluating train might differ. But anyway. Options to improve fitting within fixed architecture: - Train longer, better LR schedule. - The max-pool over 256-dim: to get more signal, maybe the input normalization matters. Using isotropic vs per-axis. - Possibly the BatchNorm eval-mode mismatch hurts. Let me check: train rel_l2 computed with model.eval() (BN running stats). The running stats might not match. Let me see if evaluating in train-mode BN gives lower (it would, using batch stats). Not relevant for final. Key insight: The architecture can't perfectly fit, so bias is significant. I want to reduce both bias (fit better) and variance (generalize). Let me think about whether a better input representation helps the network. The network sees raw xyz after standardization. Max-pooling captures the extremes. Volume/mass relates to the distribution of points. One idea: since I can choose feat_mean/feat_std but they're applied as (x-mean)/std linearly per-axis, I can't add nonlinear features. The input is strictly the 3 coords. But wait , can I choose feat_mean and feat_std to be something other than per-axis mean/std to help? For instance, scaling all axes by the same factor (isotropic) preserves aspect ratio. Let me test both and see which generalizes better. The network has BN right after the first conv, so input scaling is somewhat normalized away by BN anyway (BN after first conv normalizes per-channel). Actually BN normalizes the conv outputs, so the input scale mostly doesn't matter (a linear scaling of input → conv → BN removes scale). So feat_std choice has limited effect due to BN. The main effect is feat_mean (centering) and relative per-axis scaling (since conv mixes axes with weights, relative scale matters before BN). Hmm, the first conv is 1x1 conv from 3→64, each output channel is a linear combo of the 3 inputs, then BN normalizes each output channel. So relative scaling of the 3 input axes affects the linear combos. Per-axis std normalization makes each axis comparable. That's probably fine/good. Let me focus on the training recipe. Let me do a systematic sweep but efficiently. Given training ~0.1s/epoch (20s for 200 epochs earlier but that included eval). Let me run longer training (e.g., 600 epochs) with cosine schedule and proper EMA (lower decay), moderate augmentation, and measure val. Actually, the big question: what's the best achievable val rel_l2? Let me try several configs. Let me make EMA optional and fix its decay. Let me also implement SWA: average weights over last K epochs, then recompute BN stats. Actually, given limited capacity, maybe heavy augmentation (jitter+reflect) hurts fitting (train loss couldn't go below 0.5 with aug vs 0.12 without). Let me quantify the generalization gap and find the sweet spot of augmentation strength. Let me reconsider the earlier run: with aug (jitter0.01+reflect) raw val reached 0.33. Without aug, train got to 0.12 but I didn't measure val. Let me measure val without aug to see overfitting gap. Let me run a systematic comparison. I'll write a quick sweep function that trains with given settings and reports best val (using raw model eval every few epochs, tracking best) plus EMA. Let me also add cross-validation later. Let me first measure: no-aug val, jitter-only val, reflect-only, both , with a solid schedule and reasonable epochs, selecting best epoch on val. This tells me augmentation value. I'll train 500 epochs cosine, no EMA, eval ev …[truncated]
The architecture can't even fully memorize 100 samples (train rel-L2 floors around 0.12 with no regularization), so this max-pool PointNet has a real capacity bottleneck , bias matters as much as variance here. I need a recipe that fits well *and* generalizes. Let me sweep augmentation strength and schedule, selecting the best epoch on val, tracking all three metrics.
bash
cd /app/work && cat > sweep.py << 'PY'
import torch, math, copy
import torch.nn.functional as F
from lib import *

def run(cfg, Xtr,Ytr,Xval,Yval, fm,fs,lm,ls, device,
        epochs=500, bs=32, lr=1e-3, wd=1e-4, jitter=0.0, reflect=(False,False,False),
        rot_deg=0.0, scale_jit=0.0, seed=0, evalevery=10, warmup=15, min_lr_ratio=0.01,
        track_ema=0.0):
    torch.manual_seed(seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=lr,weight_decay=wd)
    Xtr_d=Xtr.to(device); Ytr_d=((Ytr-lm)/ls).to(device); N=Xtr_d.shape[0]
    fmd=fm.to(device); fsd=fs.to(device)
    steps_per=max(1,math.ceil(N/bs)); total=epochs*steps_per
    ema=EMA(model,track_ema) if track_ema>0 else None
    def lr_at(s):
        if s<warmup*steps_per: return lr*(s+1)/(warmup*steps_per)
        prog=(s-warmup*steps_per)/max(1,total-warmup*steps_per)
        return lr*(min_lr_ratio+(1-min_lr_ratio)*0.5*(1+math.cos(math.pi*prog)))
    best=(9,9,9); best_state=None; s=0
    for ep in range(epochs):
        model.train(); perm=torch.randperm(N,device=device)
        for i in range(0,N,bs):
            idx=perm[i:i+bs]
            if idx.numel()<2: continue
            xb=augment(Xtr_d[idx],jitter=jitter,reflect=reflect,rot_deg=rot_deg,scale_jit=scale_jit)
            xb=(xb-fmd)/fsd
            for g in opt.param_groups: g['lr']=lr_at(s)
            opt.zero_grad(set_to_none=True)
            l=F.mse_loss(model(xb),Ytr_d[idx]); l.backward(); opt.step(); s+=1
            if ema: ema.update(model)
        if (ep+1)%evalevery==0:
            if ema:
                cur=copy.deepcopy(model.state_dict()); ema.copy_to(model)
            m=metrics(predict(model,Xval,fm,fs,lm,ls,device),Yval)
            if ema: model.load_state_dict(cur)
            if m['rel_l2']<best[0]:
                best=(m['rel_l2'],m['mae'],m['maxabs'])
                src = ema.shadow if ema else model.state_dict()
                best_state={k:v.detach().cpu().clone() for k,v in src.items()}
    return best, best_state
PY
python -c "
import torch
from lib import *
from sweep import run
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
fm,fs,lm,ls=compute_norm(Xtr,Ytr)
configs={
 'no-aug':dict(jitter=0.0,reflect=(False,False,False)),
 'jit0.01':dict(jitter=0.01,reflect=(False,False,False)),
 'jit0.02':dict(jitter=0.02,reflect=(False,False,False)),
 'refl-all':dict(jitter=0.0,reflect=(True,True,True)),
 'jit0.01+refl':dict(jitter=0.01,reflect=(True,True,True)),
 'jit0.02+refl':dict(jitter=0.02,reflect=(True,True,True)),
}
for name,kw in configs.items():
    rs=[]
    for seed in [0,1,2]:
        best,_=run(cfg,Xtr,Ytr,Xval,Yval,fm,fs,lm,ls,device,epochs=400,seed=seed,**kw)
        rs.append(best)
    import numpy as np
    a=np.array(rs)
    print(f'{name:16s} rel_l2={a[:,0].mean():.4f}±{a[:,0].std():.3f}  mae={a[:,1].mean():.4f}  max={a[:,2].mean():.4f}')
"
output
no-aug           rel_l2=0.2170±0.022  mae=0.1525  max=0.4140
jit0.01          rel_l2=0.1996±0.015  mae=0.1376  max=0.3685
jit0.02          rel_l2=0.2904±0.008  mae=0.1825  max=0.7428
refl-all         rel_l2=0.2398±0.011  mae=0.1604  max=0.5111
jit0.01+refl     rel_l2=0.2829±0.013  mae=0.1842  max=0.6908
jit0.02+refl     rel_l2=0.3311±0.014  mae=0.2092  max=0.9113
Interesting results (best-epoch selection on val, averaged over 3 seeds): - no-aug: 0.217 - jit0.01: 0.200 (best) - jit0.02: 0.290 (too much jitter hurts) - refl-all: 0.240 (reflection alone hurts a bit vs no-aug) - jit0.01+refl: 0.283 (combining hurts) - jit0.02+refl: 0.331 Hmm, so light jitter (0.01) helps slightly, but reflection HURTS. That's surprising given my physical argument. Why would reflection hurt? Possibly because: 1. The val set is small (32) and best-epoch selection is noisy. 2. Reflection makes the task harder to fit (capacity bottleneck), and with limited capacity the model fits worse. Since the model can't even memorize train, adding 8x augmentation variety makes underfitting worse. 3. The brackets aren't actually reflection-symmetric in Z (chamfer 0.135), so Z-reflection creates shapes that, while valid mass-wise, are far from the data manifold, wasting capacity. Given the capacity bottleneck, augmentation that increases task difficulty hurts. Light jitter (0.01) is the sweet spot. Let me reconsider. Maybe selective reflection (only X, which had moderate symmetry, or only the most symmetric axis) helps less-aggressively. But the data shows reflection hurts overall. Let me deprioritize reflection, or test X-only reflection lightly. But wait , there's a concern about overfitting to val via best-epoch selection. The "best" over 40 evaluations (every 10 epochs for 400) on 32 samples is optimistically biased. The real generalization might be worse and the ranking might differ. I should use cross-validation for more reliable comparison. But CV is more expensive. Let me do a cleaner evaluation: use 5-fold CV over the combined 132 (train+val) samples. That gives 132 held-out predictions, much more reliable. And importantly, report the CV metric WITHOUT per-fold best-epoch cherry-picking , instead use a fixed epoch budget and EMA or final-epoch. Or select epoch by averaging across folds. Actually, for reliable model comparison, let me do this: For a given config, run 5-fold CV. In each fold, train on 4/5, predict on 1/5 held-out. Concatenate all held-out predictions → compute metrics over all 132. Use a FIXED training length (no per-fold val peeking), with EMA or just final model. This gives an honest estimate. But the capacity floor (train rel_l2 ~0.12) and val ~0.20 suggest generalization gap is moderate. Let me aim to optimize CV rel_l2. Let me reconsider the whole approach. Maybe there's a smarter move. The architecture is fixed, but I have full control of normalization constants. Let me reconsider if there's a way to exploit structure. Wait, actually , reconsider the mass determinant. Is it possible the mass is almost perfectly predictable from something I'm missing? Let me check correlation of mass with a better volume proxy: Since the shape is a surface sample, I can estimate enclosed volume via a voxel occupancy + flood fill, or via the convex hull volume, or via projecting. Let me test if convex hull volume or a voxel-based "solid volume" strongly predicts mass. If a handcrafted feature predicts mass with rel_l2 ~0.05, then the signal exists and I need the network to capture it , and I'd know the target is achievable. If even the best handcrafted feature gives ~0.2, then 0.2 is near the intrinsic difficulty. Let me compute: - Convex hull volume (scipy). - Voxel occupancy count at some resolution (surface area proxy). - Voxel solid volume via flood fill from boundary (approx interior volume). And fit a simple regression (1D or a few features) and measure rel_l2 via CV. Let me check if scipy is available.
Light jitter (0.01) helps slightly; reflection actually *hurts* , consistent with the capacity bottleneck (extra augmentation variety worsens underfitting). But 32-sample best-epoch selection is noisy and optimistic. Before trusting this, let me check the *intrinsic* difficulty: can strong handcrafted geometric features (convex-hull volume, voxel solid volume) predict mass? That tells me what rel-L2 is even achievable.
bash
cd /app/work && python -c "import scipy, sklearn; print('scipy',scipy.__version__,'sklearn',sklearn.__version__)" 2>&1 | head
output
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'scipy'
bash
cd /app/work && pip install scipy scikit-learn 2>&1 | tail -2
output
ERROR: Could not find a version that satisfies the requirement scipy (from versions: none)
ERROR: No matching distribution found for scipy
No internet. I'll implement voxel-based volume estimation in pure torch. Let me do: - Voxelize each point cloud into a grid (e.g., 32^3). Mark occupied voxels (surface). - Estimate solid volume: flood fill from the boundary of the grid to mark "outside" voxels; remaining unvisited (and not surface) voxels are "inside". Solid volume ≈ inside + surface voxels. This approximates the enclosed volume. This is a decent volume estimator assuming the surface is watertight-ish at the voxel resolution. With 2048 points, resolution 32 might have gaps (surface not fully covered → flood fill leaks inside). Let me dilate the occupied voxels first (morphological closing) to seal gaps, then flood fill. Let me implement with torch on GPU. Actually, with only 2048 surface points, sealing a watertight surface at 32^3 is hard , 32^3=32768 voxels, surface ~ maybe 6*32^2 ≈ 6000 boundary cells, 2048 points can't cover that. So leaks are likely. Lower resolution like 16^3 (4096 voxels, surface ~1500) with 2048 points is more coverable. Let me try res=16 and 20 with closing (dilation) to seal. Let me implement and correlate with mass, then do a quick CV linear/poly fit. This is mainly diagnostic to know the achievable error. Let me do it.
No internet for scipy, so I'll implement a voxel solid-volume estimator in pure PyTorch (occupancy → morphological closing to seal gaps → flood-fill outside → interior = volume) to gauge how predictable mass is.
bash
cd /app/work && cat > volfeat.py << 'PY'
import torch, torch.nn.functional as F

def voxel_volume(pc, res=20, close_iters=2):
    # pc: [P,3] in [-0.5,0.5]; returns estimated solid volume fraction + surface count
    dev=pc.device
    idx = ((pc+0.5)*res).long().clamp(0,res-1)  # [P,3]
    occ = torch.zeros(res,res,res, device=dev)
    occ[idx[:,0],idx[:,1],idx[:,2]]=1.0
    surf = occ.clone()
    # morphological closing: dilate then erode, to seal gaps
    def dilate(v,k=3):
        return (F.max_pool3d(v[None,None],k,1,k//2)[0,0]>0).float()
    def erode(v,k=3):
        return (F.max_pool3d((1-v)[None,None],k,1,k//2)[0,0]==0).float()
    v=occ
    for _ in range(close_iters): v=dilate(v)
    for _ in range(close_iters): v=erode(v)
    closed=v
    # flood fill outside: BFS from all border voxels through non-closed cells
    free=(closed==0).float()
    outside=torch.zeros_like(free)
    # seed border
    outside[0,:,:]=free[0,:,:]; outside[-1,:,:]=free[-1,:,:]
    outside[:,0,:]=free[:,0,:]; outside[:,-1,:]=free[:,-1,:]
    outside[:,:,0]=free[:,:,0]; outside[:,:,-1]=free[:,:,-1]
    for _ in range(res*3):
        new=(F.max_pool3d(outside[None,None],3,1,1)[0,0]>0).float()*free
        if torch.equal(new,outside): break
        outside=new
    inside = (1-outside)  # includes closed solid + trapped interior
    vol = inside.sum()/ (res**3)
    return vol.item(), surf.sum().item(), closed.sum().item()/(res**3)

if __name__=='__main__':
    from lib import *
    import numpy as np
    device='cuda'
    feats={}; labels={}
    for s in ['train','val','public_test']:
        X,Y=load_split(s)
        vs=[]
        for i in range(X.shape[0]):
            pc=X[i].to(device)
            vol,surf,closed=voxel_volume(pc,res=20,close_iters=2)
            vol16,_,_=voxel_volume(pc,res=16,close_iters=2)
            vol28,_,_=voxel_volume(pc,res=28,close_iters=3)
            vs.append([vol,surf,closed,vol16,vol28])
        feats[s]=np.array(vs); labels[s]=Y.squeeze(1).numpy()
    names=['vol20','surf','closed20','vol16','vol28']
    tr=feats['train']; ytr=labels['train']
    for j,nm in enumerate(names):
        c=np.corrcoef(tr[:,j],ytr)[0,1]
        print(f'{nm:10s} corr={c:+.3f}')
    np.savez('/app/work/volfeats.npz', 
             tr=feats['train'],va=feats['val'],te=feats['public_test'],
             ytr=labels['train'],yva=labels['val'],yte=labels['public_test'])
    print('saved volfeats.npz')
PY
python volfeat.py
output
vol20      corr=+0.753
surf       corr=+0.705
closed20   corr=+0.753
vol16      corr=+0.757
vol28      corr=+0.746
saved volfeats.npz
The voxel volume estimate correlates ~0.75 with mass. Better than the simple features (0.63). Let me fit a regression using these volume features and see achievable rel_l2 via CV. Let me combine vol features + maybe a few extent features and fit a small model (linear + polynomial, or just use them). Let me do a proper CV with a simple linear regression and a small MLP on these handcrafted features to estimate the achievable floor. Actually, let me also combine multiple features (vol at multiple resolutions, surf, extents, std_x etc.) and fit ridge regression / small MLP with CV over train+val (132 samples). Let me write a quick sklearn-free linear regression (normal equations) and a small torch MLP, evaluate via 5-fold CV.
Voxel solid-volume correlates ~0.75 with mass , notably better than raw coordinate stats. Let me combine a richer handcrafted feature set and fit a regression with proper 5-fold CV to estimate the achievable rel-L2 floor for this geometry→mass problem.
bash
cd /app/work && python -c "
import torch, numpy as np
from lib import *
device='cuda'
from volfeat import voxel_volume
def feat_matrix(X):
    F=[]
    for i in range(X.shape[0]):
        pc=X[i]
        mn=pc.amin(0); mx=pc.amax(0); ext=(mx-mn)
        g=pc.to(device)
        v20,surf,cl=voxel_volume(g,20,2); v16,_,_=voxel_volume(g,16,2); v24,_,_=voxel_volume(g,24,3)
        row=[v20,v16,v24,cl,surf/8000.0,
             ext[0].item(),ext[2].item(),
             pc[:,0].std().item(),pc[:,1].std().item(),pc[:,2].std().item(),
             pc.norm(dim=1).mean().item(), pc[:,0].abs().mean().item()]
        F.append(row)
    return np.array(F)
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0).squeeze(1).numpy()
FM=feat_matrix(X)
np.savez('/app/work/handfeats.npz',F=FM,Y=Y)
print('feat matrix',FM.shape)
# 5-fold CV ridge regression
def cv_ridge(F,Y,alpha=1.0,folds=5,poly=False):
    N=len(Y); idx=np.arange(N); rng=np.random.RandomState(0); rng.shuffle(idx)
    mu=F.mean(0); sd=F.std(0)+1e-8; Fn=(F-mu)/sd
    if poly:
        Fn=np.concatenate([Fn,Fn**2],1)
    preds=np.zeros(N)
    for k in range(folds):
        te=idx[k::folds]; tr=np.setdiff1d(idx,te)
        A=np.concatenate([Fn[tr],np.ones((len(tr),1))],1)
        w=np.linalg.solve(A.T@A+alpha*np.eye(A.shape[1]), A.T@Y[tr])
        At=np.concatenate([Fn[te],np.ones((len(te),1))],1)
        preds[te]=At@w
    err=preds-Y
    rel=np.sqrt((err**2).sum()/(Y**2).sum())
    return rel, np.abs(err).mean(), np.abs(err).max()
for alpha in [0.1,1.0,5.0,20.0]:
    print('ridge alpha',alpha, [round(x,4) for x in cv_ridge(FM,Y,alpha)])
for alpha in [1.0,5.0,20.0]:
    print('ridge+poly alpha',alpha, [round(x,4) for x in cv_ridge(FM,Y,alpha,poly=True)])
"
output
feat matrix (132, 12)
ridge alpha 0.1 [np.float64(0.2325), np.float64(0.1543), np.float64(0.6764)]
ridge alpha 1.0 [np.float64(0.2333), np.float64(0.1547), np.float64(0.6789)]
ridge alpha 5.0 [np.float64(0.2445), np.float64(0.1619), np.float64(0.6637)]
ridge alpha 20.0 [np.float64(0.2886), np.float64(0.1974), np.float64(0.7982)]
ridge+poly alpha 1.0 [np.float64(0.272), np.float64(0.1689), np.float64(1.1159)]
ridge+poly alpha 5.0 [np.float64(0.2902), np.float64(0.1816), np.float64(1.2785)]
ridge+poly alpha 20.0 [np.float64(0.3408), np.float64(0.2199), np.float64(1.3393)]
Handcrafted features + ridge regression gives CV rel_l2 ~0.23. That's an honest cross-validated estimate (132 samples, 5-fold). Our PointNet got val rel_l2 ~0.20 with best-epoch selection (optimistic). So the PointNet is competitive with or better than handcrafted features. The intrinsic difficulty seems to put rel_l2 around 0.2. Let me try a small MLP (gradient boosting not available) on handcrafted features with CV to see if nonlinear does better. Let me also try a torch MLP via CV. But this is diagnostic. The main takeaway: rel_l2 ~0.2 is roughly the achievable range; getting to ~0.15 would be good. Let me now get an honest CV estimate of the PointNet itself, and tune it properly. This is the real deliverable. Let me set up 5-fold CV for the PointNet over the 132 train+val samples, using a fixed training budget (EMA or final model, no per-fold peeking), and compare configs honestly. Given training is fast (~0.05s/epoch on H100 for 100 samples), 5 folds × 400 epochs is ~100s per config. Manageable. Let me reconsider EMA. The issue before was decay too high. With ~4 steps/epoch × 400 = 1600 steps, EMA decay 0.99 → 0.99^1600 ≈ tiny, so shadow catches up well; half-life = ln(2)/ln(1/0.99) ≈ 69 steps ≈ 17 epochs. That's reasonable. Decay 0.999 half-life ~173 steps ~43 epochs, okay if training 400 epochs but the early constant-output phase dominates less. Let me use EMA decay that gives good late-training averaging, e.g., 0.995 (half-life ~139 steps ~35 epochs). Or better: use larger batches → fewer steps → need lower decay. Let me just tune. Actually, a cleaner and very effective approach for small data + noisy val: average predictions across an ensemble, but we can only save ONE model into the fixed architecture. BUT , I could train multiple models and average their WEIGHTS only if they're in the same loss basin (e.g., from the same init with different data order , "model soup"). Model soups work when models are fine-tuned from the same initialization. If I train K models from the SAME seed init but different augmentation/data-order, their weights may be averageable (soup). That could reduce variance into a single checkpoint. Let me keep that idea. Alternatively, SWA: average weights over the last portion of a single training run (cyclic or high-constant LR), then recompute BN. This yields one model. Good for a single checkpoint. Let me first get an honest CV harness for the PointNet and find the best single-run recipe. Then add EMA/SWA and soup on top. Let me write a CV evaluation that, for a config, does 5-fold CV and reports pooled metrics. For each fold, I'll train and then evaluate the fold's held-out with the EMA model at the final epoch (no peeking). I'll also record metrics vs epoch to pick a good fixed epoch budget across folds. Let me build it.
Handcrafted features + ridge give an honest 5-fold CV rel-L2 ≈ 0.23, so the intrinsic difficulty sits around 0.2 and my PointNet is already competitive. The earlier 0.20 val numbers were optimistic (best-epoch cherry-picking on 32 samples). Let me build an honest 5-fold CV harness for the PointNet , no per-fold peeking , to compare recipes reliably.
bash
cd /app/work && cat > cv.py << 'PY'
import torch, math, copy, numpy as np
import torch.nn.functional as F
from lib import *

def train_fold(cfg, Xtr,Ytr, fm,fs,lm,ls, device, epochs,bs,lr,wd,
               jitter,reflect,rot_deg,scale_jit,seed,warmup,min_lr_ratio,ema_decay,
               record=None):
    torch.manual_seed(seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=lr,weight_decay=wd)
    Xtr_d=Xtr.to(device); Ytr_d=((Ytr-lm)/ls).to(device); N=Xtr_d.shape[0]
    fmd=fm.to(device); fsd=fs.to(device)
    steps_per=max(1,math.ceil(N/bs)); total=epochs*steps_per
    ema=EMA(model,ema_decay) if ema_decay>0 else None
    def lr_at(s):
        if s<warmup*steps_per: return lr*(s+1)/(warmup*steps_per)
        prog=(s-warmup*steps_per)/max(1,total-warmup*steps_per)
        return lr*(min_lr_ratio+(1-min_lr_ratio)*0.5*(1+math.cos(math.pi*prog)))
    s=0
    snaps={}
    for ep in range(epochs):
        model.train(); perm=torch.randperm(N,device=device)
        for i in range(0,N,bs):
            idx=perm[i:i+bs]
            if idx.numel()<2: continue
            xb=augment(Xtr_d[idx],jitter=jitter,reflect=reflect,rot_deg=rot_deg,scale_jit=scale_jit)
            xb=(xb-fmd)/fsd
            for g in opt.param_groups: g['lr']=lr_at(s)
            opt.zero_grad(set_to_none=True)
            F.mse_loss(model(xb),Ytr_d[idx]).backward(); opt.step(); s+=1
            if ema: ema.update(model)
        if record and (ep+1) in record:
            if ema:
                cur=copy.deepcopy(model.state_dict()); ema.copy_to(model)
                snaps[ep+1]={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
                model.load_state_dict(cur)
            else:
                snaps[ep+1]={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
    if ema: ema.copy_to(model)
    final={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
    return model, final, snaps

def cv_eval(cfg, X, Y, device, folds=5, epochs=400, bs=32, lr=1e-3, wd=1e-4,
            jitter=0.01, reflect=(False,False,False), rot_deg=0.0, scale_jit=0.0,
            seeds=(0,), warmup=15, min_lr_ratio=0.01, ema_decay=0.0, record_epochs=None,
            isotropic=False):
    N=X.shape[0]; idx=np.arange(N); rng=np.random.RandomState(123); rng.shuffle(idx)
    # pooled predictions per (epoch-snapshot or final), averaged over seeds
    rec = record_epochs or [epochs]
    pred_acc={e:torch.zeros(N) for e in rec}
    for k in range(folds):
        te=idx[k::folds]; tr=np.setdiff1d(idx,te)
        Xtr,Ytr=X[tr],Y[tr]; Xte,Yte=X[te],Y[te]
        fm,fs,lm,ls=compute_norm(Xtr,Ytr,isotropic=isotropic)
        for seed in seeds:
            model,final,snaps=train_fold(cfg,Xtr,Ytr,fm,fs,lm,ls,device,epochs,bs,lr,wd,
                jitter,reflect,rot_deg,scale_jit,seed,warmup,min_lr_ratio,ema_decay,
                record=set(rec))
            for e in rec:
                st = snaps.get(e, final)
                model.load_state_dict(st)
                p=predict(model,Xte,fm,fs,lm,ls,device).squeeze(1)
                pred_acc[e][torch.tensor(te)] += p/len(seeds)
    Yf=Y.squeeze(1)
    out={}
    for e in rec:
        out[e]=metrics(pred_acc[e],Yf)
    return out
PY
echo "cv.py ready"
output
cv.py ready
bash
cd /app/work && python -c "
import torch, time
from lib import *
from cv import cv_eval
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
rec=[150,250,400,600]
t=time.time()
configs={
 'no-aug':dict(jitter=0.0,reflect=(False,False,False)),
 'jit0.005':dict(jitter=0.005,reflect=(False,False,False)),
 'jit0.01':dict(jitter=0.01,reflect=(False,False,False)),
}
for name,kw in configs.items():
    out=cv_eval(cfg,X,Y,device,folds=5,epochs=600,bs=32,lr=1e-3,wd=1e-4,
                seeds=(0,1),ema_decay=0.0,record_epochs=rec,**kw)
    line=' | '.join([f'e{e}:rl2={out[e][\"rel_l2\"]:.4f},mae={out[e][\"mae\"]:.3f},mx={out[e][\"maxabs\"]:.3f}' for e in rec])
    print(f'{name:10s} {line}')
print('elapsed %.0fs'%(time.time()-t))
"
output
[stderr]
Shell command timed out.
Tool interaction exceeded 300 seconds.
Too slow: 3 configs × 5 folds × 2 seeds × 600 epochs. Let me restart and run in background, writing results to a file. Also reduce scope per call. Let me restart the tool.
The full sweep exceeded the time limit. Let me restart and run experiments in the background, logging to files, so I don't block on long runs.
bash
restart: true
output
<system>tool has been restarted.</system>
bash
cd /app/work && cat > exp1.py << 'PY'
import torch, time, sys
from lib import *
from cv import cv_eval
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
rec=[150,250,400]
configs={
 'no-aug':dict(jitter=0.0,reflect=(False,False,False)),
 'jit0.005':dict(jitter=0.005,reflect=(False,False,False)),
 'jit0.01':dict(jitter=0.01,reflect=(False,False,False)),
 'jit0.01+Xrefl':dict(jitter=0.01,reflect=(True,False,False)),
}
for name,kw in configs.items():
    t=time.time()
    out=cv_eval(cfg,X,Y,device,folds=5,epochs=400,bs=32,lr=1e-3,wd=1e-4,
                seeds=(0,1),ema_decay=0.0,record_epochs=rec,**kw)
    line=' | '.join([f'e{e}:rl2={out[e]["rel_l2"]:.4f},mae={out[e]["mae"]:.3f},mx={out[e]["maxabs"]:.3f}' for e in rec])
    print(f'{name:14s} {line}  ({time.time()-t:.0f}s)',flush=True)
PY
nohup python exp1.py > exp1.log 2>&1 &
echo "started pid $!"
output
started pid 353
While this runs, let me think more. Let me wait and monitor. Let me check progress periodically. Meanwhile, let me reconsider the overall strategy. Key facts: - Intrinsic difficulty ~0.2 rel_l2. - Architecture capacity-limited (can't memorize train below 0.12). - Small data (132 labeled available for final, 148 if including public_test). For the final model, I should: 1. Use the best recipe from CV. 2. Train on ALL available labeled data (train+val+public_test = 148) to maximize generalization, OR train on train+val (132) and keep public_test as check. Since hidden test is separate, using all 148 for the final is best for generalization. But I need to pick the training length without a holdout. I'll rely on CV to choose a robust fixed epoch budget and EMA/SWA so that "final epoch" is stable (not requiring early stopping). 3. To reduce variance, build a "model soup": train several models (same recipe, different seeds/data orders) and average their weights. For soups to work, models should be in the same basin , typically from the same initialization. I'll test weight-averaging of multiple seeds and check CV. If it doesn't help (different inits → different basins), I'll instead use EMA/SWA within one run and/or pick the best single seed. Actually, a more reliable "single checkpoint" ensemble trick given fixed architecture: Since I can only submit one set of weights, and prediction averaging isn't possible at inference, weight averaging (soup) is the main lever. Let me test if averaging weights across seeds helps or hurts on CV. Let me wait for exp1 results. Let me also reconsider: maybe larger batch (full-batch or 64) with BN is more stable and better for generalization. And maybe higher weight decay helps generalization. Let me plan exp2: sweep wd and bs. And exp3: EMA/SWA and soup. Let me monitor exp1.
While that runs, let me reason about the final-model strategy. Since I can only submit one weight set into the fixed architecture, prediction-averaging ensembles aren't possible at inference , but weight-averaging (EMA/SWA/"model soup") is. Let me monitor the augmentation sweep.
bash
cd /app/work && sleep 120 && cat exp1.log
output
<system>Tool ran without output or errors</system>
bash
cd /app/work && sleep 90 && cat exp1.log; echo "--- still running? ---"; ps aux | grep exp1 | grep -v grep | head
output
no-aug         e150:rl2=0.2563,mae=0.168,mx=0.740 | e250:rl2=0.2626,mae=0.166,mx=0.813 | e400:rl2=0.2609,mae=0.166,mx=0.805  (141s)
--- still running? ---
h2tagent   353 98.2 16.0 19945636 5044592 ?    Sl   21:57   3:36 python exp1.py
So honest CV for no-aug: rel_l2 ~0.256-0.26 (worse than the optimistic 0.217 from best-epoch selection). This confirms the best-epoch selection on 32 val was optimistic. 141s per config (5 folds × 2 seeds × 400 epochs + snapshots). This is slow. Let me wait for the rest. Note: each config takes ~140s, and we have 4 configs → ~560s total, already past one tool call. It's running in background, good. Let me wait more. Meanwhile, I realize the CV is somewhat slow because of the flood-fill? No, that's not in CV. It's just training 5×2=10 models × 400 epochs = 4000 epochs, ~140s → 35ms/epoch. Fine. Let me wait for remaining configs.
Honest CV for no-aug is rel-L2 ≈ 0.26 (worse than the optimistic 0.22 from best-epoch selection), confirming I should trust CV. Let me wait for the augmentation variants.
bash
cd /app/work && sleep 280 && cat exp1.log; echo "---"; ps aux|grep exp1|grep -v grep|head
output
no-aug         e150:rl2=0.2563,mae=0.168,mx=0.740 | e250:rl2=0.2626,mae=0.166,mx=0.813 | e400:rl2=0.2609,mae=0.166,mx=0.805  (141s)
jit0.005       e150:rl2=0.2753,mae=0.175,mx=0.710 | e250:rl2=0.2602,mae=0.166,mx=0.718 | e400:rl2=0.2642,mae=0.166,mx=0.778  (126s)
jit0.01        e150:rl2=0.3144,mae=0.204,mx=0.803 | e250:rl2=0.2843,mae=0.184,mx=0.785 | e400:rl2=0.2805,mae=0.183,mx=0.837  (90s)
jit0.01+Xrefl  e150:rl2=0.3401,mae=0.217,mx=0.752 | e250:rl2=0.3162,mae=0.204,mx=0.779 | e400:rl2=0.2941,mae=0.192,mx=0.648  (93s)
---
Honest CV results: - no-aug: 0.256-0.261 (best at e150: 0.2563) - jit0.005: 0.260-0.275 - jit0.01: 0.280-0.314 - jit0.01+Xrefl: 0.294-0.340 So on honest CV, augmentation HURTS. no-aug is best (~0.256). This flips my earlier optimistic conclusion. Jitter and reflection both hurt CV performance. This is consistent with the capacity bottleneck: the model underfits, so adding augmentation noise makes it worse. Wait, but that's strange , usually augmentation helps generalization. Here the model is capacity-limited and underfits even train. So augmentation, which effectively increases the diversity/difficulty, prevents the model from fitting the real signal. The variance is not the main problem; bias is. But no-aug CV (0.256) is worse than my earlier optimistic val (0.217). And worse than handcrafted ridge (0.23). Hmm, so the PointNet isn't beating handcrafted features on honest CV. I want to push it lower. Let me reconsider. Options to reduce bias (fit better / generalize better): 1. Tune LR, schedule, batch size, weight decay more carefully. 2. EMA/SWA to stabilize and maybe improve. 3. Longer training. 4. Better normalization (isotropic vs per-axis). 5. Different optimizer. Also, an important realization: maybe best at e150 suggests shorter training + some regularization is better (less overfitting to the augmentation-free train but the CV at e150 < e400 for no-aug: 0.2563 vs 0.2609, small). Let me reconsider the whole thing. Perhaps the per-axis std normalization distorts geometry in a way that hurts. Let me test isotropic normalization. Also test weight decay values and batch size. Actually, a big lever I haven't tried: the model's BatchNorm in eval mode uses running stats. With small data and my training, maybe eval-mode BN is mismatched. Let me test: does evaluating with BN in train-mode (batch stats) on the held-out differ? Not a valid inference though (grader uses eval). But I could recalibrate BN running stats after training by forward passes over training data. Let me incorporate BN recalibration. Actually, wait , there might be a subtle but important issue. At inference, the grader calls model(x) presumably in eval mode (BN uses running stats). My predict() function sets model.eval(). Good, consistent. Let me focus on a systematic hyperparameter sweep using the honest CV but FASTER. To speed up: use 1 seed, fewer folds? No, folds needed for stability. Let me use 5 folds, 1 seed, and fewer epochs (300) with record at [150,250,300]. And run configs in parallel? Single GPU, but I can run multiple configs in one script sequentially. Let me be mindful of the 300s limit by running in background. Let me design exp2 to sweep: batch size {16,32,64}, weight decay {1e-4, 1e-3, 1e-2}, normalization {per-axis, isotropic}, with no-aug and light jitter. And EMA on/off. That's a lot. Let me prioritize. Given no-aug ~0.256, let me try to beat it with: - EMA (decay tuned) - batch size 16 (more steps, maybe better fit) and 64 - weight decay higher (1e-3, 1e-2) - isotropic norm Let me write exp2 with a focused grid, 5 folds, 1 seed (seed 0), epochs 300, record [150,250,300]. Keep no-aug (jitter 0) as base. Let me include EMA decay variants by adding ema_decay param. Actually EMA needs enough steps. With bs=32, 132*4/5≈105 train per fold, ~4 steps/epoch, 300 epochs = 1200 steps. EMA 0.99 → half-life 69 steps. Good. Let me test ema_decay 0 vs 0.99 vs 0.995. Let me write exp2. I'll run several configs, log to file, keep each script under control. Let me make it efficient: one script, loop configs, each prints a line. I'll background it.
Honest CV flips the earlier conclusion: augmentation *hurts* (no-aug ≈ 0.256 is best), consistent with a bias-dominated, capacity-limited model that underfits. I need to reduce bias. Let me sweep the real levers , batch size, weight decay, isotropic vs per-axis normalization, and EMA , via honest CV.
bash
cd /app/work && cat > exp2.py << 'PY'
import torch, time
from lib import *
from cv import cv_eval
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
rec=[200,300]
base=dict(folds=5,epochs=300,lr=1e-3,jitter=0.0,reflect=(False,False,False),
          seeds=(0,),record_epochs=rec)
configs={
 'bs32 wd1e-4 ema0':   dict(bs=32,wd=1e-4,ema_decay=0.0),
 'bs32 wd1e-4 ema.99': dict(bs=32,wd=1e-4,ema_decay=0.99),
 'bs32 wd1e-3 ema0':   dict(bs=32,wd=1e-3,ema_decay=0.0),
 'bs32 wd1e-2 ema0':   dict(bs=32,wd=1e-2,ema_decay=0.0),
 'bs16 wd1e-4 ema0':   dict(bs=16,wd=1e-4,ema_decay=0.0),
 'bs64 wd1e-4 ema0':   dict(bs=64,wd=1e-4,ema_decay=0.0),
 'bs32 wd1e-3 iso':    dict(bs=32,wd=1e-3,ema_decay=0.0,isotropic=True),
}
for name,kw in configs.items():
    t=time.time()
    k2={**base,**kw}
    out=cv_eval(cfg,X,Y,device,**k2)
    line=' | '.join([f'e{e}:rl2={out[e]["rel_l2"]:.4f},mae={out[e]["mae"]:.3f},mx={out[e]["maxabs"]:.3f}' for e in rec])
    print(f'{name:20s} {line}  ({time.time()-t:.0f}s)',flush=True)
PY
nohup python exp2.py > exp2.log 2>&1 &
echo "started $!"
output
started 400
bash
cd /app/work && sleep 240 && cat exp2.log
output
bs32 wd1e-4 ema0     e200:rl2=0.2587,mae=0.167,mx=0.716 | e300:rl2=0.2611,mae=0.167,mx=0.714  (56s)
bs32 wd1e-4 ema.99   e200:rl2=0.2579,mae=0.169,mx=0.701 | e300:rl2=0.2606,mae=0.167,mx=0.713  (58s)
bs32 wd1e-3 ema0     e200:rl2=0.2540,mae=0.160,mx=0.670 | e300:rl2=0.2554,mae=0.158,mx=0.681  (57s)
bs32 wd1e-2 ema0     e200:rl2=0.2512,mae=0.164,mx=0.717 | e300:rl2=0.2501,mae=0.160,mx=0.718  (56s)
So far: - bs32 wd1e-4: 0.259-0.261 - bs32 wd1e-4 ema.99: ~0.258 (EMA barely helps) - bs32 wd1e-3: 0.254-0.255 (better) - bs32 wd1e-2: 0.250-0.251 (best so far) Higher weight decay helps. Let me wait for bs16, bs64, iso. Higher wd (1e-2) giving 0.250. Let me push wd even higher later (3e-2, 1e-1). Let me wait for the rest.
Higher weight decay clearly helps (wd=1e-2 → 0.250). EMA barely moves the needle. Let me see the batch-size and isotropic results.
bash
cd /app/work && sleep 180 && cat exp2.log; echo "---"; ps aux|grep exp2|grep -v grep|head
output
bs32 wd1e-4 ema0     e200:rl2=0.2587,mae=0.167,mx=0.716 | e300:rl2=0.2611,mae=0.167,mx=0.714  (56s)
bs32 wd1e-4 ema.99   e200:rl2=0.2579,mae=0.169,mx=0.701 | e300:rl2=0.2606,mae=0.167,mx=0.713  (58s)
bs32 wd1e-3 ema0     e200:rl2=0.2540,mae=0.160,mx=0.670 | e300:rl2=0.2554,mae=0.158,mx=0.681  (57s)
bs32 wd1e-2 ema0     e200:rl2=0.2512,mae=0.164,mx=0.717 | e300:rl2=0.2501,mae=0.160,mx=0.718  (56s)
bs16 wd1e-4 ema0     e200:rl2=0.2571,mae=0.162,mx=0.807 | e300:rl2=0.2544,mae=0.159,mx=0.788  (60s)
bs64 wd1e-4 ema0     e200:rl2=0.2644,mae=0.169,mx=0.826 | e300:rl2=0.2577,mae=0.162,mx=0.782  (52s)
bs32 wd1e-3 iso      e200:rl2=0.2744,mae=0.170,mx=0.857 | e300:rl2=0.2717,mae=0.165,mx=0.837  (56s)
---
Results: - bs16 wd1e-4: 0.254-0.257 - bs64 wd1e-4: 0.258-0.264 - bs32 wd1e-3 iso: 0.272-0.274 (isotropic WORSE than per-axis) So per-axis normalization is better than isotropic. Best so far: bs32 wd1e-2 (0.250). Let me now push weight decay higher and explore the wd×bs interaction, plus LR. Higher wd (1e-2) helped. Let me try wd in {2e-2, 5e-2, 1e-1} with bs32, and also combine with bs16. Also try lr {5e-4, 2e-3}. Also maybe longer training (500-600) with high wd. And maybe the high-wd model benefits from EMA or more epochs. Let me also reconsider: with strong wd acting as regularizer, maybe light augmentation now helps (since we reduced overfitting via wd, aug might add robustness). But earlier aug hurt with low wd. Let me not overcomplicate; test aug with high wd quickly too. Let me run exp3: focus on wd sweep + lr, bs in {16,32}, epochs 400, record [200,300,400]. Since each config ~56s, I can run ~8 configs in ~450s (background). Let me craft exp3. Actually, let me be efficient and targeted: configs: - bs32 lr1e-3 wd2e-2 - bs32 lr1e-3 wd5e-2 - bs32 lr1e-3 wd1e-1 - bs16 lr1e-3 wd2e-2 - bs16 lr1e-3 wd5e-2 - bs32 lr2e-3 wd2e-2 - bs32 lr5e-4 wd2e-2 - bs32 lr1e-3 wd3e-2 + jit0.005 record [250,400]. epochs 400. Let me run.
Per-axis normalization beats isotropic, and wd=1e-2 is best so far (0.250). Let me push weight decay higher and probe its interaction with LR and batch size.
bash
cd /app/work && cat > exp3.py << 'PY'
import torch, time
from lib import *
from cv import cv_eval
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
rec=[250,400]
base=dict(folds=5,epochs=400,reflect=(False,False,False),seeds=(0,),record_epochs=rec)
configs={
 'bs32 lr1e-3 wd2e-2':   dict(bs=32,lr=1e-3,wd=2e-2,jitter=0.0),
 'bs32 lr1e-3 wd5e-2':   dict(bs=32,lr=1e-3,wd=5e-2,jitter=0.0),
 'bs32 lr1e-3 wd1e-1':   dict(bs=32,lr=1e-3,wd=1e-1,jitter=0.0),
 'bs16 lr1e-3 wd2e-2':   dict(bs=16,lr=1e-3,wd=2e-2,jitter=0.0),
 'bs16 lr1e-3 wd5e-2':   dict(bs=16,lr=1e-3,wd=5e-2,jitter=0.0),
 'bs32 lr2e-3 wd2e-2':   dict(bs=32,lr=2e-3,wd=2e-2,jitter=0.0),
 'bs32 lr5e-4 wd2e-2':   dict(bs=32,lr=5e-4,wd=2e-2,jitter=0.0),
 'bs32 lr1e-3 wd3e-2 jit.005': dict(bs=32,lr=1e-3,wd=3e-2,jitter=0.005),
}
for name,kw in configs.items():
    t=time.time()
    out=cv_eval(cfg,X,Y,device,**{**base,**kw})
    line=' | '.join([f'e{e}:rl2={out[e]["rel_l2"]:.4f},mae={out[e]["mae"]:.3f},mx={out[e]["maxabs"]:.3f}' for e in rec])
    print(f'{name:28s} {line}  ({time.time()-t:.0f}s)',flush=True)
PY
nohup python exp3.py > exp3.log 2>&1 &
echo "started $!"
output
started 435
While exp3 runs (~8 configs × ~70s = ~560s), let me think about additional ideas to reduce bias/variance. Idea: The PointNet global feature is a single max-pooled 256-vector. This loses a lot. But architecture is fixed. However , I realize I might be able to help the model by choosing feat_mean/feat_std such that the input is positioned well. But BN normalizes anyway. Another thought: the head has GELU activations and the final output. With high weight decay, the head weights shrink. Good. Another idea for variance reduction into a single checkpoint: model soup across seeds AND across folds? No, folds use different normalization. For the FINAL model I train on all data with one normalization; I can make a soup of several seeds trained on all-data. Let me test whether seed-averaging (soup) improves CV: in cv_eval, within each fold, train K seeds and average their weights, then evaluate. That's a clean test of soup. But soup across different random inits often fails. Let me test it. Actually, there's a better, well-founded approach for a single checkpoint: train with a moderate LR and average the weights over the last N epochs (SWA) within ONE run. This stays in one basin and reliably improves. Or EMA (which I tested, gave tiny improvement). The EMA improvement was tiny though (0.2587→0.2579). SWA might help more with a high constant LR at the end. Hmm, but improvements are marginal. The dominant factor is weight decay. Let me focus there first, then add EMA/SWA for a small boost and stability. Let me also consider: is there label noise or outliers? The max abs error ~0.7 suggests one or a few samples are far off. Since worst-case abs err is a metric, I should look at which samples are hardest. Let me later examine per-sample CV errors to see if a few outliers dominate. If a few heavy brackets (mass ~2.2) are systematically under-predicted (regression to mean), that hurts rel_l2 and maxabs. Possibly I should weight heavy samples more, or the high-mass tail is just hard. Let me check the error distribution vs mass after I pick a good config. Let me wait for exp3. Actually, let me reconsider whether predicting in a transformed label space could help the tail. The inference transform is linear (pred*ls+lm), so I can't use log. But I could choose label_mean/label_std differently , still linear. A linear transform doesn't change relative errors structure. So no help there. However, I could weight the loss to emphasize high-mass samples (since they contribute more to rel_l2 numerator if under-predicted, and to maxabs). Actually rel_l2 numerator = sum of squared errors (unweighted). MSE already optimizes that. For maxabs, I might use a loss that penalizes large errors more (e.g., higher-order). But MSE is standard. Let me consider a slight tweak: add a term for the max error or use p=4 norm. Could help worst-case but may hurt average. Let me hold off. Let me wait and check exp3.
While exp3 runs, let me reason about worst-case error: the max abs error (~0.7) suggests a few hard brackets (likely the heavy, high-mass tail) dominate. I'll examine per-sample CV errors once I lock the regularization. Let me check exp3 progress.
bash
cd /app/work && sleep 290 && cat exp3.log
output
bs32 lr1e-3 wd2e-2           e250:rl2=0.2476,mae=0.159,mx=0.683 | e400:rl2=0.2467,mae=0.158,mx=0.687  (75s)
bs32 lr1e-3 wd5e-2           e250:rl2=0.2622,mae=0.171,mx=0.669 | e400:rl2=0.2578,mae=0.165,mx=0.654  (58s)
bs32 lr1e-3 wd1e-1           e250:rl2=0.2643,mae=0.171,mx=0.694 | e400:rl2=0.2571,mae=0.161,mx=0.709  (63s)
bs16 lr1e-3 wd2e-2           e250:rl2=0.2562,mae=0.160,mx=0.826 | e400:rl2=0.2553,mae=0.158,mx=0.803  (76s)
- bs32 lr1e-3 wd2e-2: 0.2467-0.2476 (best so far!) - bs32 lr1e-3 wd5e-2: 0.258-0.262 (worse, too much) - bs32 lr1e-3 wd1e-1: 0.257-0.264 - bs16 lr1e-3 wd2e-2: 0.255-0.256 So wd=2e-2 with bs32 is the sweet spot (~0.247). wd=1e-2 gave 0.250, wd=2e-2 gives 0.247, wd=5e-2 worse. Let me wait for lr and jitter variants.
wd=2e-2 with bs32 is the new best (≈0.247); beyond that (5e-2, 1e-1) it degrades. Let me see the LR and jitter variants.
bash
cd /app/work && sleep 220 && cat exp3.log | tail -5; echo "---"; ps aux|grep exp3|grep -v grep|head
output
bs32 lr1e-3 wd1e-1           e250:rl2=0.2643,mae=0.171,mx=0.694 | e400:rl2=0.2571,mae=0.161,mx=0.709  (63s)
bs16 lr1e-3 wd2e-2           e250:rl2=0.2562,mae=0.160,mx=0.826 | e400:rl2=0.2553,mae=0.158,mx=0.803  (76s)
bs16 lr1e-3 wd5e-2           e250:rl2=0.2534,mae=0.159,mx=0.734 | e400:rl2=0.2546,mae=0.160,mx=0.753  (80s)
bs32 lr2e-3 wd2e-2           e250:rl2=0.2622,mae=0.169,mx=0.712 | e400:rl2=0.2454,mae=0.155,mx=0.733  (75s)
bs32 lr5e-4 wd2e-2           e250:rl2=0.2558,mae=0.160,mx=0.769 | e400:rl2=0.2526,mae=0.157,mx=0.722  (73s)
---
h2tagent   435 98.5 16.0 19938492 5038396 ?    Sl   22:13   8:42 python exp3.py
- bs32 lr2e-3 wd2e-2: e400 0.2454 (best so far, but e250 0.262 , needs full training) - bs32 lr5e-4 wd2e-2: 0.253-0.256 - bs16 lr5e-2: 0.253-0.255 So lr2e-3 wd2e-2 at e400 gives 0.2454. lr1e-3 wd2e-2 gave 0.2467. Close. Let me wait for the jitter variant (last one). Current best region: bs32, lr ~1-2e-3, wd 2e-2, ~400 epochs → ~0.245-0.247. Let me think about whether I can do meaningfully better than ~0.245. The handcrafted ridge gave 0.23. Maybe combining the network with... no, must be the network alone. Hmm, what if I increase training epochs for lr2e-3 (cosine) , maybe 600 epochs better. And add EMA/SWA. Let me also consider that the model is underfitting; maybe a warmup + longer schedule helps it reach lower bias. Let me also reconsider: could the per-axis normalization be improved by NOT dividing by std but using a fixed scale? We saw isotropic (single std) was worse. Per-axis std is best. Another idea to reduce bias: since max-pooling is the bottleneck, maybe a different input ordering or duplicating coordinate info... can't change architecture. Let me check the last jitter result then design exp4: fine-tune around bs32, lr 1.5e-3/2e-3, wd 1.5e-2/2e-2/3e-2, epochs 500-600, with EMA and SWA, 2-3 seeds for stability, and examine per-sample errors. Let me wait for exp3 to finish.
LR=2e-3 with wd=2e-2 reaches 0.2454 at e400. The best region is clearly bs32, lr≈1–2e-3, wd≈2e-2. Let me get the final jitter line, then fine-tune around this optimum with longer schedules, EMA/SWA, and multiple seeds for stable estimates.
bash
cd /app/work && sleep 60 && tail -1 exp3.log
output
bs32 lr1e-3 wd3e-2 jit.005   e250:rl2=0.2625,mae=0.168,mx=0.611 | e400:rl2=0.2678,mae=0.171,mx=0.673  (66s)
jitter + wd3e-2 gives 0.262-0.268 , jitter still hurts even with regularization, though it reduced maxabs (0.611). So no jitter for average rel_l2, but jitter helps worst-case. Interesting trade-off. But overall no-aug is better for rel_l2 and mae. Now let me fine-tune around the optimum and test EMA/SWA and multiple seeds. Let me design exp4: - Confirm best config with 3 seeds (seed-averaged predictions in CV = like a soup via prediction averaging; but for reporting stability I'll average over seeds which reduces noise in the estimate, not an ensemble of the final model). Actually in cv_eval, seeds average the PREDICTIONS (pred_acc += p/len(seeds)), which IS a prediction ensemble. For honest single-model estimate I should use seeds=(0,) OR report per-seed. But seed-averaging in CV tells me the ensemble performance, not single model. Since final model is single, I want single-model CV. But I can use multiple seeds to estimate the mean single-model performance by... my current code averages predictions. Let me add a mode to evaluate per-seed and average the METRICS (not predictions) to get expected single-model performance. Or just run seeds=(0,),(1,),(2,) separately. Let me modify: I'll just run with seeds=(0,) but repeat the whole cv with different global seeds to see variance. Actually simpler: let me evaluate both , single-model (seed 0) and 3-seed prediction-ensemble , to know the gap. If ensemble helps a lot, I'll pursue weight-soup for the final single checkpoint. Let me write exp4 carefully: 1. Best config candidates at longer epochs with EMA and SWA. 2. Check seed-ensemble vs single. Let me add SWA to cv. Actually, let me add a simpler approach: evaluate the final-epoch model AND an EMA model in the same run. My train_fold already supports ema_decay. And record snapshots. Let me just compare ema_decay values at longer epochs. Let me also implement weight-soup test: train N seeds per fold, average weights, evaluate. Let me add that as a separate function. Given time constraints (tokens), let me be efficient. Let me run exp4 with: - cfg A: bs32 lr1.5e-3 wd2e-2 epochs600 ema0, seeds (0,) and also (0,1,2) ensemble - cfg B: same with ema.99 - Evaluate soup of 3 seeds per fold. Let me write it to directly compare single vs ensemble vs soup, plus EMA, at the chosen config. Let me add a soup function to cv.py.
Jitter still hurts average rel-L2 (though it helps worst-case max error , a useful lever to remember). Now let me fine-tune the optimum with a longer schedule and test variance-reduction for a *single* checkpoint: single-seed vs a 3-seed prediction ensemble vs a weight "soup" (averaging seed weights). If soup matches the ensemble, I get ensemble-quality in one checkpoint.
bash
cd /app/work && cat >> cv.py << 'PY'

def cv_soup(cfg, X, Y, device, folds=5, epochs=400, bs=32, lr=1.5e-3, wd=2e-2,
            jitter=0.0, reflect=(False,False,False), seeds=(0,1,2), warmup=15,
            min_lr_ratio=0.01, ema_decay=0.0):
    # Compare: single(seed0), pred-ensemble(seeds), weight-soup(seeds)
    N=X.shape[0]; idx=np.arange(N); rng=np.random.RandomState(123); rng.shuffle(idx)
    pred_single=torch.zeros(N); pred_ens=torch.zeros(N); pred_soup=torch.zeros(N)
    for k in range(folds):
        te=idx[k::folds]; tr=np.setdiff1d(idx,te)
        Xtr,Ytr=X[tr],Y[tr]; Xte,Yte=X[te],Y[te]
        fm,fs,lm,ls=compute_norm(Xtr,Ytr)
        states=[]
        for si,seed in enumerate(seeds):
            model,final,_=train_fold(cfg,Xtr,Ytr,fm,fs,lm,ls,device,epochs,bs,lr,wd,
                jitter,reflect,0.0,0.0,seed,warmup,min_lr_ratio,ema_decay,record=None)
            states.append(final)
            p=predict(model,Xte,fm,fs,lm,ls,device).squeeze(1)
            pred_ens[torch.tensor(te)] += p/len(seeds)
            if si==0: pred_single[torch.tensor(te)] = p
        # soup
        soup={k2: sum(s[k2].float() for s in states)/len(states) for k2 in states[0]}
        model.load_state_dict(soup)
        p=predict(model,Xte,fm,fs,lm,ls,device).squeeze(1)
        pred_soup[torch.tensor(te)] = p
    Yf=Y.squeeze(1)
    return dict(single=metrics(pred_single,Yf), ensemble=metrics(pred_ens,Yf), soup=metrics(pred_soup,Yf))
PY
cat > exp4.py << 'PY'
import torch, time
from lib import *
from cv import cv_soup
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
for tag,kw in {
  'lr1.5e-3 wd2e-2 e500 ema0':dict(lr=1.5e-3,wd=2e-2,epochs=500,ema_decay=0.0),
  'lr1.5e-3 wd2e-2 e500 ema.99':dict(lr=1.5e-3,wd=2e-2,epochs=500,ema_decay=0.99),
}.items():
    t=time.time()
    r=cv_soup(cfg,X,Y,device,folds=5,bs=32,seeds=(0,1,2,3),**kw)
    print(tag,flush=True)
    for k,v in r.items():
        print(f'   {k:9s} rl2={v["rel_l2"]:.4f} mae={v["mae"]:.4f} max={v["maxabs"]:.4f}',flush=True)
    print(f'   ({time.time()-t:.0f}s)',flush=True)
PY
nohup python exp4.py > exp4.log 2>&1 &
echo "started $!"
output
started 480
This runs 5 folds × 4 seeds × 500 epochs × 2 configs. That's a lot: ~ 5*4*500 = 10000 epochs per config × 2 = 20000 epochs, at ~35ms/epoch ≈ 700s. Might exceed a single wait. Running in background, I'll poll. Let me wait and meanwhile think about the final pipeline. While waiting, let me prepare the final training script that: 1. Loads train+val (+optionally public_test) → all labeled data. 2. Computes normalization. 3. Trains the chosen config. 4. If soup helps: trains K seeds on ALL data, averages weights, recomputes BN stats by forward passes over training data in train mode, then saves. 5. Saves checkpoint with required keys. For BN recalibration after soup: averaging BN weights/biases and running stats across seeds is okay-ish, but the running stats from different seeds averaged might be slightly off. Better: after souping, do a few forward passes over the full training data in train() mode to refresh running_mean/var (reset them first). I'll implement reset+recalc BN. Let me wait for exp4 to see if soup works. Let me poll.
exp4 is heavy (5 folds × 4 seeds × 500 epochs × 2 configs). Let me poll it while I prepare the final pipeline.
bash
cd /app/work && sleep 280 && cat exp4.log
output
<system>Tool ran without output or errors</system>
bash
cd /app/work && sleep 200 && cat exp4.log; echo ---; ps aux|grep exp4|grep -v grep|head -1
output
lr1.5e-3 wd2e-2 e500 ema0
   single    rl2=0.2443 mae=0.1549 max=0.6875
   ensemble  rl2=0.2720 mae=0.1647 max=0.8557
   soup      rl2=0.4652 mae=0.3084 max=1.4547
   (326s)
---
h2tagent   480 98.8 15.8 19897188 4996068 ?    Sl   22:23   8:02 python exp4.py
Interesting and important findings: - single (seed 0): rl2=0.2443 - ensemble (4 seeds, prediction avg): rl2=0.2720 , WORSE than single! - soup (weight avg): rl2=0.4652 , much worse (basically broken, as expected for different inits in different basins). Wait, the ensemble being WORSE than single is very surprising. Prediction averaging of 4 seeds should reduce variance and typically help or at least not hurt much. Unless different seeds converge to quite different functions and averaging predictions... no, averaging predictions of unbiased-ish estimators reduces variance. Getting worse suggests high bias differences or that seed 0 was just luckier. Actually, wait , the "single" uses only seed 0. The ensemble averages seeds 0-3. If seeds 1,2,3 are individually worse than seed 0 AND biased in a correlated direction, the average could be worse than seed 0. But rl2 0.272 vs 0.244 , the ensemble is notably worse. This suggests high seed-to-seed variance and that averaging predictions in mass-space might shift things. Hmm, or maybe the individual seeds 1,2,3 are worse (e.g., 0.26-0.28 each) and seed 0 (0.244) happened to be good. The ensemble ≈ average ≈ 0.27 which is near the mean of individual performances. That means seed 0 was lucky, and the TRUE expected single-model performance is ~0.26-0.27, not 0.244! This is a crucial insight: relying on seed 0's 0.244 is optimistic. The expected single-model CV is closer to 0.26. And prediction-ensembling didn't help reduce error here (it gave ~average, not better), which is odd , normally ensemble < average of singles. Let me reconsider: maybe the issue is that individual seeds have LOW variance but correlated errors (all miss the same hard samples), so ensembling doesn't help; and seed 0 just got a better draw. Actually ensemble error being ~0.27 while seed0 single is 0.244: if all seeds were iid with mean 0.26, the ensemble of predictions would have LOWER error than any single (variance reduction on the noise component). Unless the dominant error is bias (systematic, same across seeds) , then ensembling can't help, and ensemble ≈ single-with-less-noise ≈ maybe slightly better than average single. The ensemble = 0.272 being worse than single seed0 = 0.244 means seed0 is below-average and the bias floor is ~0.27. Hmm, but ensemble should still be ≤ the RMS-average of singles. Let me get the per-seed singles to understand. Let me not over-interpret; I should measure per-seed single performance. The soup is broken (0.465) , confirming different-init soup doesn't work. So for the final single checkpoint, I can't use multi-seed soup. Key takeaway: 1. Single-model CV ≈ 0.25-0.27 depending on seed. 2. I need to reduce variance for a RELIABLE single checkpoint. EMA within-run is the tool. Let me check the ema.99 result (still running). Wait , but prediction ensembling hurt. That's weird enough that I want to double check the ensemble logic. In cv_soup, pred_ens accumulates p/len(seeds) for each seed → correct average. pred_single set at si==0. These look right. Let me reconsider: maybe the problem is BatchNorm in eval mode with small fold training data → the running stats are noisy and seed-dependent, causing prediction shifts. Averaging predictions across seeds with different BN biases could increase error if biases are systematically offset. Actually if each seed has a different constant bias, averaging reduces the variance of the bias → should help. Unless biases are mean-nonzero... Let me just empirically get per-seed single CV values to know the real distribution. Let me wait for ema.99 result first, then run a per-seed breakdown. Actually, I should pivot strategy given these findings: - The expected single model is ~0.26 with high variance. - To get a reliable, good single checkpoint, EMA/SWA within a run is the main tool (stays in one basin, reduces variance). - I should pick the config + seed that's robust. But I can't pick seed based on hidden test. However, I CAN pick ba …[truncated]
Three key findings: (1) the weight "soup" across seeds is broken (0.465) , different inits land in different basins, so I cannot average independent seeds; (2) the 4-seed prediction ensemble (0.272) is *worse* than seed-0 alone (0.244), meaning seed-0 was a lucky draw and expected single-model CV is really ~0.26 with high seed variance; (3) errors look bias-dominated. So for a reliable single checkpoint I should use within-run weight averaging (EMA/SWA), not multi-seed tricks. Let me see the EMA line and then measure per-seed spread.
bash
cd /app/work && sleep 180 && cat exp4.log; echo ---; ps aux|grep exp4|grep -v grep|head -1
output
lr1.5e-3 wd2e-2 e500 ema0
   single    rl2=0.2443 mae=0.1549 max=0.6875
   ensemble  rl2=0.2720 mae=0.1647 max=0.8557
   soup      rl2=0.4652 mae=0.3084 max=1.4547
   (326s)
lr1.5e-3 wd2e-2 e500 ema.99
   single    rl2=0.2433 mae=0.1540 max=0.6819
   ensemble  rl2=0.2718 mae=0.1643 max=0.8524
   soup      rl2=0.4652 mae=0.3084 max=1.4547
   (320s)
---
Hmm wait, the soup result is IDENTICAL for ema0 and ema.99 (0.4652, same mae/max). That's suspicious , it suggests my soup/single/ensemble for the two configs gave identical numbers because... Actually the single differs slightly (0.2443 vs 0.2433) and ensemble differs slightly. But soup is bit-identical. That's odd. Oh I see , in cv_soup, when ema_decay>0, train_fold returns `final` which is the EMA-copied model. But the soup uses `states` = final states. For ema0, final is the raw model; for ema.99, final is EMA. Yet soup is identical... That strongly implies the soup computation is dominated by something seed-independent, OR there's a bug where soup always collapses to near-constant (0.465 = mean predictor). Indeed 0.465 ≈ the mean-predictor rel_l2 (~0.47). So souping different-init models gives a near-constant output (weights average toward canceling → output ~mean). The fact it's identical across the two runs is because the RNG seeds for folds/init are the same, so the souped weights produce nearly identical (near-mean) outputs. Fine , soup is just broken, confirmed. Now, EMA barely changes single (0.2443 → 0.2433). So EMA within-run gives negligible improvement here. The big issue remains: seed variance. single seed0 = 0.244 but ensemble (avg of 4 seeds) = 0.272, implying other seeds are worse. Let me get per-seed single CV to see the spread. This matters because my final model is a single seed trained on all data , I need to know the expected performance and reduce variance. Let me run a quick per-seed single CV (5 folds, seeds 0..5 separately, report each). Use the best config (lr1.5e-3 wd2e-2 e500, or maybe e400 to be faster). Let me use epochs=400 for speed. Actually, let me reconsider the ensemble-worse-than-single puzzle more carefully, because it affects strategy. Let me compute, for seeds 0..5, the single-model CV rel_l2 each. If they range e.g. 0.24–0.30, then seed matters a lot and I must be careful. Let me write exp5: per-seed single CV, best config, 5 folds, seeds 0-5, epochs 400. Also I'll compute the ensemble of all as reference. This will take ~6 seeds × 5 folds × 400 = 12000 epochs ≈ 420s. Background it. While that runs, let me also reconsider a fundamentally different angle to reduce variance and bias: What if I reduce the effective randomness by using a fixed initialization that's good? Can't control much. Alternatively , accept ~0.25 and focus on making the single final model robust by: - Training on ALL 148 labeled samples (more data → lower variance & bias). - Using EMA. - Choosing epoch/lr/wd from CV. - Possibly averaging the LAST-few-epochs snapshots (SWA) within the run for stability. The extra data (148 vs 105 per fold) should itself reduce error below the CV estimate. Good. Let me run exp5 for per-seed spread. Let me also include a test: does averaging snapshots across epochs (SWA, same run) help? I'll add that. Let me keep it simpler: exp5 = per-seed single CV, config lr1.5e-3 wd2e-2 e400, seeds 0-5.
The soup is confirmed broken (collapses to ~mean predictor, 0.465), and EMA only changes results negligibly (0.2443→0.2433). The ensemble-worse-than-single result means seed variance is large, so I must quantify it. Let me measure per-seed single-model CV across seeds 0–5 to understand the spread and set realistic expectations.
bash
cd /app/work && cat > exp5.py << 'PY'
import torch, time, numpy as np
from lib import *
from cv import train_fold
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
N=X.shape[0]; idx=np.arange(N); rng=np.random.RandomState(123); rng.shuffle(idx)
def cv_single(seed, epochs=400, lr=1.5e-3, wd=2e-2, ema_decay=0.99):
    pred=torch.zeros(N)
    for k in range(5):
        te=idx[k::5]; tr=np.setdiff1d(idx,te)
        Xtr2,Ytr2=X[tr],Y[tr]
        fm,fs,lm,ls=compute_norm(Xtr2,Ytr2)
        model,final,_=train_fold(cfg,Xtr2,Ytr2,fm,fs,lm,ls,device,epochs,32,lr,wd,
            0.0,(False,False,False),0.0,0.0,seed,15,0.01,ema_decay,record=None)
        model.load_state_dict(final)
        pred[torch.tensor(te)]=predict(model,X[te],fm,fs,lm,ls,device).squeeze(1)
    return metrics(pred,Y.squeeze(1)), pred
Yf=Y.squeeze(1)
allp=[]
for seed in range(6):
    t=time.time()
    m,pred=cv_single(seed)
    allp.append(pred)
    print(f'seed{seed}: rl2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} max={m["maxabs"]:.4f} ({time.time()-t:.0f}s)',flush=True)
ens=torch.stack(allp).mean(0)
me=metrics(ens,Yf)
print(f'6-seed ens: rl2={me["rel_l2"]:.4f} mae={me["mae"]:.4f} max={me["maxabs"]:.4f}',flush=True)
torch.save(torch.stack(allp),'seedpreds.pt')
PY
nohup python exp5.py > exp5.log 2>&1 &
echo "started $!"
output
started 527
While exp5 runs, let me think about the ensemble-worse-than-single puzzle differently. In exp4, the 4-seed ensemble was 0.272 while seed0 single was 0.244. If per-seed singles in exp5 show e.g. seed0=0.244, seed1=0.28, seed2=0.26..., then the ensemble being ~0.27 means variance reduction isn't happening the way I'd expect (it should pull toward lower). Actually, wait. Let me reconsider. Prediction ensembling reduces the VARIANCE component of error but not the BIAS. If errors are mostly bias (systematic, same sign across seeds for each sample , e.g., all seeds under-predict heavy brackets), then ensemble error ≈ bias ≈ similar to each single's error. The ensemble would be close to the RMS of the average prediction. If seed0 happened to have bias that partially cancels variance favorably... it's noise. The ensemble rel_l2 (0.272) being HIGHER than seed0 (0.244) but presumably LOWER than the worst seeds suggests seed0 is just a good draw. The ensemble should be near the "expected" performance with variance removed, i.e., ≈ bias floor. So the bias floor is ~0.27 and seed0 got lucky dipping to 0.244 via favorable variance. This means: a single model's TRUE expected rel_l2 ≈ 0.27, with lucky/unlucky draws ±0.02. The bias floor (ensemble) ≈ 0.27. But hold on , that means prediction-ensembling does NOT beat the bias floor, and a single good model can even "beat" the ensemble by luck. So to get a good SINGLE model reliably, I want to (a) reduce bias, (b) reduce variance, (c) train on more data. The ensemble being worse than seed0 is just because seed0 < bias_floor by luck while ensemble ≈ bias_floor. Over many test sets, seed0's advantage wouldn't hold. Hmm, actually that interpretation has a flaw: ensembling reduces variance, so ensemble error ≤ sqrt(bias² + var/K). Single error ~ sqrt(bias² + var). So ensemble should be ≤ single in expectation. seed0 being < ensemble means seed0's realized error (on this particular CV draw) was below its expectation , possible since the "test" here is fixed (the 132 samples). Across seeds, the variance part is the only thing changing (same data). So for THIS fixed dataset, seed0 realized low error. Another seed might realize high. The ensemble averages out seed-variance → stable ~0.27. For the hidden test, I get ONE model (one seed). Its performance will be a draw from the seed distribution. To be safe, I want to minimize expected error AND variance. Training on more data reduces variance. EMA reduces variance a bit. But actually , here's a thought. The seed-variance comes from different random inits + data orders landing in different basins with different biases on specific samples. A prediction ensemble averages these → stable. Since I can't ensemble at inference, maybe I can approximate the ensemble's stability by training ONE model on all data with EMA and hope it's near the bias floor (~0.25-0.27). Alternatively, reduce variance via a DIFFERENT trick: train one model but with a very long EMA/SWA tail and low final LR so it settles into a flat minimum (flatter = lower variance, better generalization). Let me also reconsider: maybe I can reduce the bias floor by a better training objective or by addressing the hard samples. Let me examine WHERE the errors are (which samples). If a few heavy brackets dominate, I might weight them. Let me wait for exp5 and the seed predictions, then analyze per-sample errors. Let me monitor.
exp5 will quantify seed variance. While it runs, let me reconsider the interpretation: if errors are bias-dominated (all seeds miss the same hard brackets), ensembling can't help and seed-0 was just a favorable draw , the real expected single-model rel-L2 is likely ~0.26–0.27. Training the final model on all 148 labeled samples should push below the 132-sample CV estimate. Let me check progress and then analyze which samples drive the error.
bash
cd /app/work && sleep 250 && cat exp5.log
output
seed0: rl2=0.2530 mae=0.1632 max=0.6720 (74s)
seed1: rl2=0.2888 mae=0.1729 max=0.9322 (70s)
seed2: rl2=0.3091 mae=0.1831 max=0.9746 (71s)
Per-seed single CV: - seed0: 0.253 - seed1: 0.289 - seed2: 0.309 Big variance! 0.25 to 0.31. Note these use the SAME config but different seeds, and ema_decay=0.99. (The earlier exp4 seed0 gave 0.2433 with e500; here e400 gives 0.253 , consistent-ish.) So the seed variance is large (~0.25-0.31). This is concerning for a single checkpoint. The expected is ~0.28, and a bad seed gives 0.31. I need to reduce this variance. Let me think: 1. Train on more data (148 vs 105/fold) → less variance. 2. The prediction ensemble of seeds gave ~0.27 (stable). If only I could deploy an ensemble... Wait , I CAN effectively deploy a prediction ensemble within a SINGLE forward pass IF the models could be merged. Soup fails for different inits. But what about training multiple models from the SAME init (same seed for init) but different data orders / augmentation? Models fine-tuned from the same init often stay in the same basin and soup well. Let me test "same-init soup": fix init seed, vary only the data-order/batching seed. Average weights. This might work and give ensemble-like stability in one checkpoint. Actually even better and well-established: SWA (Stochastic Weight Averaging) along a single trajectory reliably finds flatter minima and reduces variance. Let me test SWA: train one run, then average weights over the last ~K epochs (collected periodically), recompute BN. This is a single-basin average → valid single checkpoint. Let me reconsider: EMA is a form of weight averaging along the trajectory and gave basically same as single (0.243 vs 0.244 in exp4). That suggests trajectory-averaging near the end doesn't reduce the seed-level variance (which comes from the basin chosen early). Hmm. The seed variance comes from which basin/solution the model finds. EMA/SWA within a basin won't fix that. Same-init soup idea: if I fix the initialization (same init weights) and vary data order, do the models stay close enough to soup? With BN and 400 epochs, they may diverge. But worth testing , it's the only way to get ensemble-like behavior in one checkpoint. Alternatively: Knowledge distillation! Train a prediction-ensemble of K models (the good, stable ~0.27 ensemble), then distill it into a single model of the SAME architecture by training the single model to match the ensemble's predictions on the training data (and maybe on augmented/interpolated point clouds). The distilled single model can approach the ensemble's performance and stability, and it's a valid single checkpoint. This is promising but complex and the student has the same limited capacity , it might not capture the ensemble better than just training normally. But distillation often transfers the "smoothed" function → lower variance. Given capacity limits, distillation to same arch may help reduce variance (student mimics stable teacher). Hmm, but the ensemble itself is only 0.27, barely better than a good single seed (0.25). The gain from distillation would be stability, landing ~0.26-0.27 reliably instead of risking 0.31. Let me step back and think about what matters: I submit ONE checkpoint. Its hidden-test rel_l2 is a draw. I want to maximize expected performance and minimize downside. Plan: 1. Reduce variance by training on ALL data (148). 2. Pick the best config from CV. 3. Use the val set (or a CV-like internal check) to SELECT among a few candidate final models (different seeds) , but selecting on 32 val is itself noisy and could pick a val-overfit model. Still, selecting the best-of-few on val likely beats a random single, as long as val correlates with hidden test. Given val and hidden test are same distribution, moderate correlation exists. Let me check how well CV-fold performance correlates with val performance to gauge if selection is reliable. Actually, a cleaner variance-reduction: train K seeds on all data, and SELECT the one with best val rel_l2. But val is only 32 samples → noisy selection. Alternatively, select the "medoid" model , the one whose pr …[truncated]
Seed variance is large (0.253 → 0.289 → 0.309 across seeds 0–2), which is a real risk for a single checkpoint. I need a variance-reduction method that yields ONE valid checkpoint. Since cross-init soup is broken, I'll evaluate a principled "medoid" selection (pick the model whose predictions are closest to the multi-seed mean , no label overfitting) and distillation. Let me get the full seed spread and the 6-seed ensemble first.
bash
cd /app/work && sleep 220 && cat exp5.log
output
seed0: rl2=0.2530 mae=0.1632 max=0.6720 (74s)
seed1: rl2=0.2888 mae=0.1729 max=0.9322 (70s)
seed2: rl2=0.3091 mae=0.1831 max=0.9746 (71s)
seed3: rl2=0.2939 mae=0.1821 max=0.9419 (70s)
seed4: rl2=0.2726 mae=0.1644 max=0.8335 (71s)
seed5: rl2=0.2858 mae=0.1776 max=0.8471 (70s)
6-seed ens: rl2=0.2699 mae=0.1658 max=0.8383
Per-seed single CV: 0.253, 0.289, 0.309, 0.294, 0.273, 0.286. Mean ≈ 0.284, std ≈ 0.019. Range 0.253-0.309. 6-seed ensemble: 0.270. So ensemble (0.270) is better than the MEAN single (0.284) but not better than the BEST single (0.253). Ensemble reduces variance → lands near the better end but not the luckiest. So expected single ≈ 0.284, best-case 0.253. Ensemble 0.270. Now the medoid idea: pick the model whose predictions are closest to the 6-seed mean. Let me compute that from saved seedpreds.pt to see which seed it'd pick and whether it's a good one. If medoid picks a central seed with ~0.27-0.28, that's the "typical" one, which doesn't beat random-average much. Hmm, medoid ≈ typical, not necessarily best. What actually helps: the ensemble (0.270) is the stable target. To deploy ensemble-like behavior in a single checkpoint, distillation is the route. Let me test distillation quickly in CV. But wait, let me reconsider the magnitude of gains. Going from expected single 0.284 to ensemble 0.270 is a ~5% relative improvement. Distillation might recover part of that and mainly reduce variance (avoid the 0.31 unlucky draw). Worth it but modest. A bigger lever might be reducing the bias floor itself. The ensemble at 0.270 is the bias floor with this config. Can I lower it? Ideas: - More data (train on 148 for final, but CV can't show this). - Different architecture-compatible tricks. Limited. Let me reconsider the handcrafted ridge (0.23 CV) which BEAT the PointNet ensemble (0.27)! The handcrafted voxel-volume feature is more informative than what PointNet learns. This strongly hints the PointNet is failing to capture the volume signal that voxel features capture. Can I help the PointNet capture volume? The architecture takes raw xyz → shared MLP → max pool. Max-pooling captures the "extremal" points (silhouette/extent), not volume/density. That's why it correlates with extent but struggles with volume. Idea: What if I change the INPUT representation via feat_mean/feat_std? No, linear only, can't add density info. BUT , here's a powerful idea: I control the input POINTS? No , at inference, the grader feeds the given [B,2048,3] cloud directly into (x-feat_mean)/feat_std → model. I cannot preprocess the points at inference (no hook). The grader applies only the linear normalization. So I can't voxelize at inference. Confirmed I'm stuck with raw points → fixed net. Wait, let me re-read the task to be sure about the inference path. "feat_mean, feat_std - length-3 tensors used to standardize the input coordinates before the forward pass. The same normalization is applied at inference time." So yes, only linear per-axis standardization, then forward. No custom preprocessing. So I must rely on the network. So the PointNet's max-pool bias limits it to ~0.27, while voxel features get 0.23. I can't inject voxel features. Unless... I distill the voxel-feature knowledge INTO the PointNet via training targets? No , distillation changes targets, not the model's ability to compute volume. The PointNet still can't compute volume from max-pool regardless of targets. Actually, the PointNet CAN approximate volume-correlated quantities through the pre-maxpool per-point features and the head; it's not literally only extent. With enough training it learns some volume proxy (it got 0.27). The ceiling for THIS architecture is what it is. Let me test: can I push the PointNet below 0.27 by training on ALL data? I can't CV that directly, but I can estimate by training on 132 and testing on the 16 public_test (held out from the 132). Let me do that: train on train+val (132), eval on public_test (16). This gives an out-of-sample estimate using more training data. Multiple seeds → see public_test performance. Actually, public_test has only 16 samples and a narrower mass range (0.198-1.449, no heavy brackets), so its rel_l2 may look better than reality. Let me still use it as one more signal. Let me refocus and be decisive given token budget. Here …[truncated]
The full picture: per-seed singles span 0.253–0.309 (mean ≈ 0.284), the 6-seed ensemble is a stable 0.270, and , notably , handcrafted voxel-volume features hit 0.23, beating the PointNet. The max-pool architecture is the ceiling. My leverage now is (a) the tuned recipe, (b) training on all 148 labeled samples, and (c) a variance-reduction method that yields ONE checkpoint. Let me test distillation (student mimics the stable ensemble) and leak-free medoid selection against plain single/ensemble in honest CV.
bash
cd /app/work && cat > exp6.py << 'PY'
import torch, time, numpy as np, math, copy
import torch.nn.functional as F
from lib import *
from cv import train_fold
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
N=X.shape[0]; idx=np.arange(N); rng=np.random.RandomState(123); rng.shuffle(idx)
Yf=Y.squeeze(1)
K=6; EP=400; LR=1.5e-3; WD=2e-2

def distill_student(Xtr2, soft_tr, fm,fs,lm,ls, seed, epochs=400):
    torch.manual_seed(1000+seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=LR,weight_decay=WD)
    Xd=Xtr2.to(device); yd=((soft_tr-lm)/ls).to(device); n=Xd.shape[0]
    fmd=fm.to(device); fsd=fs.to(device); spp=max(1,math.ceil(n/32)); tot=epochs*spp; s=0
    for ep in range(epochs):
        model.train(); perm=torch.randperm(n,device=device)
        for i in range(0,n,32):
            ii=perm[i:i+32]
            if ii.numel()<2: continue
            xb=(Xd[ii]-fmd)/fsd
            for g in opt.param_groups:
                s2=s
                g['lr']=LR*((s2+1)/(15*spp) if s2<15*spp else 0.01+0.99*0.5*(1+math.cos(math.pi*(s2-15*spp)/max(1,tot-15*spp))))
            opt.zero_grad(set_to_none=True)
            F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); s+=1
    return model

pred_single=torch.zeros(N); pred_ens=torch.zeros(N); pred_med=torch.zeros(N); pred_dist=torch.zeros(N)
t0=time.time()
for k in range(5):
    te=idx[k::5]; tr=np.setdiff1d(idx,te)
    Xtr2,Ytr2=X[tr],Y[tr]; Xte=X[te]
    fm,fs,lm,ls=compute_norm(Xtr2,Ytr2)
    teach_te=[]; teach_tr=[]
    for seed in range(K):
        m,final,_=train_fold(cfg,Xtr2,Ytr2,fm,fs,lm,ls,device,EP,32,LR,WD,
            0.0,(False,False,False),0.0,0.0,seed,15,0.01,0.99,record=None)
        m.load_state_dict(final)
        teach_te.append(predict(m,Xte,fm,fs,lm,ls,device).squeeze(1))
        teach_tr.append(predict(m,Xtr2,fm,fs,lm,ls,device).squeeze(1))
    T_te=torch.stack(teach_te); T_tr=torch.stack(teach_tr)
    ens_te=T_te.mean(0); ens_tr=T_tr.mean(0)
    pred_single[torch.tensor(te)]=teach_te[0]
    pred_ens[torch.tensor(te)]=ens_te
    # medoid: teacher closest to ens on TRAIN preds
    d=((T_tr-ens_tr[None])**2).mean(1); j=int(d.argmin())
    pred_med[torch.tensor(te)]=teach_te[j]
    # distill student on ensemble train preds
    stu=distill_student(Xtr2, ens_tr.unsqueeze(1), fm,fs,lm,ls, 0, epochs=EP)
    pred_dist[torch.tensor(te)]=predict(stu,Xte,fm,fs,lm,ls,device).squeeze(1)
    print(f'fold{k} done medoid=seed{j} ({time.time()-t0:.0f}s)',flush=True)
for nm,p in [('single',pred_single),('ensemble',pred_ens),('medoid',pred_med),('distill',pred_dist)]:
    m=metrics(p,Yf); print(f'{nm:9s} rl2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} max={m["maxabs"]:.4f}',flush=True)
PY
nohup python exp6.py > exp6.log 2>&1 &
echo "started $!"
output
started 558
This runs 5 folds × (6 teachers + 1 student) × 400 epochs = 5*7*400 = 14000 epochs ≈ 8-9 min. Let me wait and poll. Meanwhile let me analyze the per-sample errors from saved seedpreds.pt to understand the hard samples.
exp6 will take several minutes. While it runs, let me analyze *which* samples drive the error, using the saved per-seed CV predictions , this tells me whether a few heavy brackets dominate the worst-case and rel-L2.
bash
cd /app/work && python -c "
import torch, numpy as np
from lib import *
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
Y=torch.cat([Ytr,Yval],0).squeeze(1)
P=torch.load('seedpreds.pt')  # [6,N]
N=P.shape[1]; idx=np.arange(N); rng=np.random.RandomState(123); rng.shuffle(idx)
ens=P.mean(0)
err=(ens-Y)
# sort by abs err
order=err.abs().argsort(descending=True)
print('worst 12 samples (ensemble): idx  true  pred  err')
for i in order[:12].tolist():
    print(f'  {i:3d}  true={Y[i]:.3f} pred={ens[i]:.3f} err={err[i]:+.3f}')
print('corr(true,err):', np.corrcoef(Y.numpy(),err.numpy())[0,1])
print('mean true of worst-10:', Y[order[:10]].mean().item(), 'overall mean:', Y.mean().item())
# how much of rel_l2^2 numerator from top-5?
num=err.pow(2).sum().item(); top5=err[order[:5]].pow(2).sum().item()
print(f'top-5 samples account for {100*top5/num:.0f}% of squared error')
print('fraction true>1.3:', (Y>1.3).float().mean().item(), 'count', (Y>1.3).sum().item())
"
output
worst 12 samples (ensemble): idx  true  pred  err
   49  true=0.521 pred=1.359 err=+0.838
  119  true=1.584 pred=0.898 err=-0.686
   34  true=1.584 pred=0.902 err=-0.682
   48  true=2.219 pred=1.589 err=-0.630
   55  true=0.515 pred=1.103 err=+0.589
   23  true=0.777 pred=1.303 err=+0.526
    9  true=0.853 pred=0.350 err=-0.503
   94  true=0.573 pred=0.984 err=+0.411
  106  true=0.593 pred=0.979 err=+0.387
  100  true=0.403 pred=0.783 err=+0.380
   71  true=0.303 pred=0.675 err=+0.372
  104  true=0.802 pred=1.162 err=+0.359
corr(true,err): -0.5001171269065756
mean true of worst-10: 0.9620361328125 overall mean: 0.7511226534843445
top-5 samples account for 35% of squared error
fraction true>1.3: 0.11363636702299118 count 15
[stderr]
<string>:6: FutureWarning: You are using `torch.load` with `weights_only=False` (the current default value), which uses the default pickle module implicitly. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling (See https://[code-host]/pytorch/pytorch/blob/main/SECURITY.md#untrusted-models for more details). In a future release, the default value for `weights_only` will be flipped to `True`. This limits the functions that could be executed during unpickling. Arbitrary objects will no longer be allowed to be loaded via this mode unless they are explicitly allowlisted by the user via `torch.serialization.add_safe_globals`. We recommend you start setting `weights_only=True` for any use case where you don't have full control of the loaded file. Please open an issue on [code-host] for any issues related to this experimental feature.
Very informative: - corr(true, err) = -0.50: the model regresses to the mean , it UNDER-predicts heavy brackets (true high → err negative) and OVER-predicts light brackets (true low → err positive). Classic regression-to-mean / shrinkage, partly from weight decay and limited capacity. - Worst samples: idx49 true=0.521 but pred=1.359 (big over-prediction of a light bracket!), idx48 true=2.219 pred=1.589 (under-predict heaviest). - Interesting: idx49 (true 0.521) predicted 1.359 , a light bracket predicted very heavy. And idx119/34 (true 1.584) predicted ~0.9. So there are specific shapes that fool the model badly (geometry whose "bulk appearance" misleads the max-pool). - top-5 samples = 35% of squared error. So worst-case and rel_l2 are dominated by a handful. The regression-to-mean (shrinkage) is notable. This comes from: (a) weight decay shrinking outputs toward mean, (b) MSE with limited capacity → predicts conditional mean but compresses range, (c) the model genuinely can't distinguish extremes. Idea to reduce shrinkage: the strong weight decay (2e-2) may be over-shrinking. But lower wd increased variance/overfitting. There's a tension. However, the shrinkage toward mean could be partially corrected by a POST-HOC linear calibration: fit pred_calibrated = a*pred + b to de-shrink. Since the inference transform is pred_model*label_std + label_mean (linear), and label_mean/label_std are MINE to choose, I could bake a de-shrinking gain into label_std! Wait: the model outputs standardized mass z. Inference: mass = z*label_std + label_mean. If the model's z is shrunk toward 0 (predicting less spread than true), I can INCREASE label_std to counteract shrinkage. But that also scales the noise. The optimal linear calibration minimizing MSE is: find a,b to best map model output to true. Actually MSE-optimal prediction is already the conditional mean; de-shrinking increases variance but if the model systematically compresses, a linear gain >1 reduces bias at cost of variance. The MSE-optimal linear rescale is regression of true on pred: slope = cov(true,pred)/var(pred). If the model is well-calibrated (MSE-optimal), slope=1. But shrinkage suggests the model's outputs under-cover → slope>1 improves MSE. Hmm, but if the model already minimizes MSE on training data, then on training data slope≈1 by construction. The shrinkage appears on held-out (CV) data because the model overfits train and regresses to mean on unseen. A held-out-calibrated gain could help generalization. I can estimate the calibration via CV (out-of-fold predictions) and bake it into label_std/label_mean. This is a legit, powerful trick! Specifically: Let z = model output (standardized-space prediction). Define final mass = A*z + B. Normally A=label_std, B=label_mean. I can choose A,B to minimize CV error. Using OOF predictions, fit linear regression true ~ z. Since z is in standardized space (roughly predicting (mass-mean)/std), I'd get A≈label_std if calibrated, but if shrunk, A>label_std. But careful: the model is trained with label_mean/label_std fixed (to standardize targets). The z it outputs targets (mass-label_mean)/label_std. If I then de-standardize with DIFFERENT A,B than training's label_std,label_mean, that's exactly a post-hoc linear calibration. This is fine and the checkpoint format supports it (label_mean,label_std are just the de-standardization constants). So the plan: train model with standard label norm. Then compute OOF (or val) predictions, fit mass ≈ A*z + B, and SAVE label_std=A, label_mean=B (the calibrated ones). This directly counteracts shrinkage and should improve rel_l2, mae. Let me verify the shrinkage slope from the seed predictions. ens predictions vs true: fit true = a*ens + b (ens is already in mass units). If a>1, de-shrink helps. Let me compute. Actually the ens preds are already in mass units (predict() applies label_std,label_mean). So I fit true ~ a*ens+b. If a>1 → de-shrink. Let me compute this calibration and the …[truncated]
Critical insight: `corr(true, err) = −0.50` , the model systematically **regresses to the mean** (under-predicts heavy brackets, over-predicts light ones), and the top-5 samples drive 35% of the squared error. Since the inference de-standardization `mass = z·label_std + label_mean` is linear and those constants are *mine to choose*, I can bake in a post-hoc "de-shrinking" calibration. Let me quantify that gain on the out-of-fold predictions.
bash
cd /app/work && python -c "
import torch, numpy as np
from lib import *
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
Y=torch.cat([Ytr,Yval],0).squeeze(1).numpy()
P=torch.load('seedpreds.pt',weights_only=True).numpy()  # [6,N] OOF preds in mass units
def relL2(p): 
    e=p-Y; return np.sqrt((e**2).sum()/(Y**2).sum()), np.abs(e).mean(), np.abs(e).max()
# per-seed and ensemble, with/without linear calibration fit on OOF (proper would be nested, approx here)
for name,p in [('seed0',P[0]),('ens',P.mean(0))]:
    a,b=np.polyfit(p,Y,1)
    pc=a*p+b
    print(f'{name}: raw rl2/mae/max={tuple(round(x,4) for x in relL2(p))}  slope={a:.3f} b={b:.3f}')
    print(f'       calibrated   ={tuple(round(x,4) for x in relL2(pc))}')
"
output
seed0: raw rl2/mae/max=(np.float32(0.253), np.float32(0.1632), np.float32(0.672))  slope=0.930 b=0.039
       calibrated   =(np.float64(0.2507), np.float64(0.1613), np.float64(0.7412))
ens: raw rl2/mae/max=(np.float32(0.2699), np.float32(0.1658), np.float32(0.8383))  slope=0.938 b=0.030
       calibrated   =(np.float64(0.2679), np.float64(0.1651), np.float64(0.7835))
Interesting , the slope is 0.93 (<1), meaning the OOF predictions are only slightly shrunk, and a global linear calibration barely helps (0.253→0.2507). So global linear de-shrinking gives negligible gain. The corr(true,err)=-0.5 reflects per-sample scatter more than a global slope issue. So calibration won't help much. The residual is genuine noise/bias on specific shapes, not a simple global scale. Hmm. So the errors are largely irreducible with this architecture (specific shapes mispredicted). The slope being 0.93 means only mild shrinkage. OK, drop the calibration idea (it gives <1% improvement, and fitting it risks slight overfit; I might still apply a mild, safe calibration but it's marginal). So where does that leave me? The realistic achievable CV rel_l2 for this architecture is ~0.25-0.27 (single ~0.28 expected, ensemble 0.27, best seed 0.25). Let me reconsider the bias floor. The handcrafted voxel ridge got 0.23. The PointNet ens 0.27. Gap exists. Is there any way to push the PointNet toward 0.23? Let me reconsider augmentation ONE more time, but a specific kind: the reason aug hurt might be that I was disrupting the signal. But what about a tiny amount of jitter purely for the worst-case (it reduced maxabs in exp3: jit helped max go to 0.611). Since worst-case is a metric, a tiny jitter might trade a bit of rel_l2 for better maxabs. But rel_l2 is primary. I'll keep no-aug for the main model but remember jitter reduces maxabs. Let me reconsider the number of training epochs and LR schedule more. And whether a 2-cycle / restart schedule finds better minima. Actually, let me step back on time/token budget. I've spent a lot. Let me lock in a solid, well-validated final model rather than chase marginal gains. Decision for FINAL: - Recipe: bs32, lr1.5e-3, wd2e-2, no-aug, per-axis norm, cosine (warmup 15), ~500 epochs, EMA 0.99. - Train on ALL 148 labeled samples (train+val+public_test) to maximize data → lower bias & variance than the 132-sample CV, so I expect hidden-test rel_l2 somewhat better than the 0.27-0.28 CV (more data + the hidden set being similar distribution). - Variance reduction for single checkpoint: wait for exp6 (distill/medoid) result. If distill or medoid beats single reliably, use it. Otherwise, I'll do medoid selection over K seeds (leak-free) as a safe default, OR pick by a small held-out. Actually, let me reconsider using public_test as a final selection set vs folding it into training. Given only 148 total and the hidden test presumably ~32-ish samples, more training data is valuable. But having a clean selection signal is also valuable. Compromise: Train final candidates on train+val (132). Select using public_test (16) + centrality. Then OPTIONALLY retrain the chosen approach on all 148 for the final submission (no selection needed if the method is deterministic-ish). Hmm. Let me see exp6 first , it's the key experiment. Let me check progress.
The predictions are only mildly shrunk (slope 0.93), so global linear calibration barely helps (<1%) , the errors are genuine per-shape noise, not a fixable global scale. I'll skip calibration. Let me check the distillation/medoid experiment, which is the key decision for turning the stable ensemble into one deployable checkpoint.
bash
cd /app/work && sleep 120 && cat exp6.log
output
fold0 done medoid=seed2 (91s)
Slow , 91s per fold × 5 = ~460s. Let me wait more. Meanwhile let me reconsider and prepare the final training script so I'm ready once exp6 finishes. Let me think about the "train on all 148" plan and how to pick the single checkpoint robustly: Given exp6 will tell me if distill/medoid helps. Let me prepare a final script that supports: - Training K seeds on a given dataset with the locked recipe + EMA. - Options: pick seed0, pick medoid, or distill. - BN handling: EMA includes BN running stats; after loading final EMA state, BN stats are the EMA'd ones which should be fine. For medoid I pick an actual trained model's state (fine). For distill, the student is a normally-trained model (fine). Let me also make sure to SAVE the checkpoint in the exact required format and VERIFY it loads into candidate_model.build_model and produces [B,1]. Let me write the final script now while exp6 runs, then decide the selection method from exp6. Let me also reconsider: should I include public_test in final training data? The hidden test is separate. Using all 148 maximizes data. I'll train final on all 148. For selection method validation, exp6 uses CV on 132 (train+val). The conclusion about distill/medoid should transfer. Let me write final.py. Actually, let me reconsider distillation's purpose once more. The ensemble (0.27) barely beats single-expected (0.28) and is worse than best seed (0.25). Distillation aims to get ensemble-like stability. But since ensemble isn't much better than a typical single, distillation's upside is mostly variance reduction (avoid 0.31 unlucky). Medoid also reduces variance by picking a central seed. Both are about avoiding bad draws. Alternatively, the simplest robust approach: train MANY seeds, and pick the medoid (most central). This reliably avoids outlier-bad seeds. Let me make sure exp6 shows medoid ≈ ensemble (good). Let me wait. Let me prepare final.py meanwhile.
exp6 is slow (~90s/fold). Let me wait, and meanwhile write the final training script so I'm ready to act on the result. I'll make it flexible (single / medoid / distill) and have it verify the saved checkpoint loads into the fixed architecture.
bash
cd /app/work && cat > final_train.py << 'PY'
import torch, math, copy, numpy as np, argparse, time
import torch.nn.functional as F
from lib import *
from cv import train_fold

cfg=load_cfg(); device='cuda'
LR=1.5e-3; WD=2e-2; BS=32; EP=500; WARM=15; EMAD=0.99

def get_all_data(include_test=True):
    Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
    parts=[(Xtr,Ytr),(Xval,Yval)]
    if include_test:
        Xte,Yte=load_split('public_test'); parts.append((Xte,Yte))
    X=torch.cat([p[0] for p in parts],0); Y=torch.cat([p[1] for p in parts],0)
    return X,Y

@torch.no_grad()
def recalc_bn(model, Xn, device, passes=3, bs=64):
    # reset and recompute BN running stats from normalized inputs Xn (already standardized)
    for m in model.modules():
        if isinstance(m, torch.nn.BatchNorm1d):
            m.reset_running_stats(); m.momentum=None  # cumulative average
    model.train()
    n=Xn.shape[0]
    for _ in range(passes):
        perm=torch.randperm(n)
        for i in range(0,n,bs):
            xb=Xn[perm[i:i+bs]].to(device)
            if xb.shape[0]<2: continue
            model(xb)
    model.eval()

def train_one(X,Y,fm,fs,lm,ls,seed,epochs=EP):
    model,final,_=train_fold(cfg,X,Y,fm,fs,lm,ls,device,epochs,BS,LR,WD,
        0.0,(False,False,False),0.0,0.0,seed,WARM,0.01,EMAD,record=None)
    model.load_state_dict(final)
    return model, final

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument('--method',default='medoid',choices=['single','medoid'])
    ap.add_argument('--kseeds',type=int,default=9)
    ap.add_argument('--epochs',type=int,default=EP)
    ap.add_argument('--include_test',type=int,default=1)
    ap.add_argument('--out',default='/app/outputs/model.pt')
    ap.add_argument('--bn_recalc',type=int,default=1)
    args=ap.parse_args()
    X,Y=get_all_data(bool(args.include_test))
    print('final train data:',X.shape[0],'samples',flush=True)
    fm,fs,lm,ls=compute_norm(X,Y)
    Xn=((X.to(device)-fm.to(device))/fs.to(device)).cpu()
    states=[]; preds_tr=[]
    for seed in range(args.kseeds):
        t=time.time()
        model,final=train_one(X,Y,fm,fs,lm,ls,seed,args.epochs)
        if args.bn_recalc:
            model.load_state_dict(final); recalc_bn(model,Xn,device)
            final={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
        states.append(final)
        model.load_state_dict(final)
        p=predict(model,X,fm,fs,lm,ls,device).squeeze(1)
        preds_tr.append(p)
        print(f'  seed{seed} trained ({time.time()-t:.0f}s) train-rl2={metrics(p,Y.squeeze(1))["rel_l2"]:.4f}',flush=True)
    P=torch.stack(preds_tr); ens=P.mean(0)
    if args.method=='single' or args.kseeds==1:
        chosen=states[0]; ci=0
    else:
        d=((P-ens[None])**2).mean(1); ci=int(d.argmin()); chosen=states[ci]
    print(f'chosen method={args.method} idx={ci}',flush=True)
    payload={
        'state_dict':{k:v.cpu() for k,v in chosen.items()},
        'feat_mean':fm.squeeze().cpu().float(),
        'feat_std':fs.squeeze().cpu().float(),
        'label_mean':lm.squeeze().cpu().float().reshape(1),
        'label_std':ls.squeeze().cpu().float().reshape(1),
    }
    import os; os.makedirs('/app/outputs',exist_ok=True)
    torch.save(payload,args.out)
    print('saved',args.out,flush=True)

if __name__=='__main__':
    main()
PY
echo "final_train.py ready"
output
final_train.py ready
bash
cd /app/work && sleep 200 && cat exp6.log
output
fold0 done medoid=seed2 (91s)
fold1 done medoid=seed2 (178s)
fold2 done medoid=seed3 (227s)
fold3 done medoid=seed5 (277s)
fold4 done medoid=seed3 (342s)
single    rl2=0.2530 mae=0.1632 max=0.6720
ensemble  rl2=0.2699 mae=0.1658 max=0.8383
medoid    rl2=0.2977 mae=0.1803 max=0.9419
distill   rl2=0.2594 mae=0.1639 max=0.7019
exp6 results: - single (seed0): 0.2530 - ensemble: 0.2699 - medoid: 0.2977 (WORSE! medoid picked central seeds that happened to be bad) - distill: 0.2594 (between single and ensemble; good mae 0.1639 and decent max 0.7019) So: - Medoid is BAD (0.298) , centrality in prediction space doesn't mean low error; it picked seeds 2,3,5 which were among the worse ones (from exp5: seed2=0.309, seed3=0.294, seed5=0.286). So medoid is unreliable. Drop medoid. - Distill (0.2594) is better than expected-single (0.284) and close to ensemble, with good MAE (0.164) and good max (0.702). Distillation is the winner for a single checkpoint! It gives stable, near-ensemble performance and notably better worst-case than ensemble. Wait, but "single" here is seed0=0.253 which is the LUCKY seed. The fair comparison is distill (0.259) vs EXPECTED single (0.284 from exp5). Distill beats expected single by a good margin and is stable (deterministic given teachers). Also distill's max (0.702) is much better than ensemble's (0.838) and single seed0 (0.672), comparable to best. So distillation is the best strategy for a single robust checkpoint. Let me use it. But note: exp6's "single"=0.253 is seed0 which was lucky. Distill=0.259 is close to that and far better than the 0.28-0.31 bad seeds. So distillation reliably gives ~0.259 , excellent and robust. Let me refine distillation: - Teacher ensemble: K seeds (more is better for teacher quality/stability). Use K=8-10. - Student: train on ensemble's predictions over training inputs. Could also blend with true labels (alpha). Pure ensemble targets gave 0.259. Let me consider blending true labels might help student not inherit ensemble bias; but ensemble is smoother. Let me test a couple of distillation variants quickly: - pure ensemble targets (done: 0.259) - blend: 0.5*ensemble + 0.5*true - also distill with more teacher seeds (K=8) and maybe augmented distillation data (generate extra point clouds via jitter/reflection and use ensemble predictions on them as additional training targets , this gives the student MORE data to mimic the teacher, which can improve distillation a lot! The teacher provides labels for free on any input). That last idea is powerful: distillation data augmentation. I can create many augmented versions of the training clouds (jitter, reflection, resample), label them with the teacher ensemble (which is stable), and train the student on this enlarged set. Since reflection/jitter produce valid inputs and the teacher gives consistent targets, the student gets a richer training signal → better generalization. Even though direct augmentation hurt (because true labels + capacity), distillation-augmentation might help because the teacher provides smooth consistent targets on augmented inputs (no label noise issue). Worth testing. Given token budget, let me be judicious. Let me test distillation variants in CV quickly (reuse exp6 structure but only distill branch, with variants). Actually that's expensive. Let me instead just improve the final pipeline with distillation + light distillation-time augmentation and validate on CV once. Let me design exp7: CV test of distillation variants: - distill_pure (baseline 0.259) - distill_aug (student sees jittered+reflected copies, teacher-labeled) - distill_blend (targets = 0.5 ens + 0.5 true) Use K=6 teachers, 5 folds. This is ~ as expensive as exp6 (~350s) but only training students extra. Actually teachers dominate cost. Let me reuse: train teachers once per fold, then train 3 student variants. ~5 folds × (6 teachers + 3 students)=45 trainings ×400 ep. ~ similar to exp6 +50%. ~500s. Background it. Let me write exp7. For distill_aug: during student training, for each batch, apply augmentation to inputs AND recompute teacher targets on-the-fly? That requires teachers at train time (expensive but doable , 6 teachers forward per batch). Simpler: precompute an enlarged dataset: original + R augmented copies, with teacher ensemble labels compu …[truncated]
Decisive result: **distillation wins** (0.259, with good MAE 0.164 and worst-case 0.702), beating the *expected* single (0.284) and nearly matching the lucky seed-0, while being stable. Medoid is unreliable (0.298 , centrality ≠ accuracy). Distillation gives near-ensemble quality in one deployable checkpoint. A powerful extension: the teacher can label *augmented* clouds for free (no label-noise problem that hurt direct augmentation), giving the student far more consistent training signal. Let me test distillation with augmented transfer data.
bash
cd /app/work && cat > exp7.py << 'PY'
import torch, time, numpy as np, math
import torch.nn.functional as F
from lib import *
from cv import train_fold
cfg=load_cfg(); device='cuda'
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val')
X=torch.cat([Xtr,Xval],0); Y=torch.cat([Ytr,Yval],0)
N=X.shape[0]; idx=np.arange(N); rng=np.random.RandomState(123); rng.shuffle(idx)
Yf=Y.squeeze(1); K=8; EP=400; LR=1.5e-3; WD=2e-2

def make_aug(Xt, copies, jitter, reflect, seedbase=0):
    outs=[Xt]
    for c in range(copies):
        g=torch.Generator(device=device).manual_seed(seedbase*97+c+1)
        xb=Xt.clone()
        if reflect:
            for a in range(3):
                fl=(torch.rand(xb.shape[0],1,generator=g,device=device)<0.5).float()*(-2)+1
                xb[:,:,a]=xb[:,:,a]*fl
        if jitter>0:
            xb=xb+ (torch.randn(xb.shape,generator=g,device=device)*jitter).clamp(-0.03,0.03)
        outs.append(xb)
    return torch.cat(outs,0)

def train_student(Xin, soft, fm,fs,lm,ls, epochs=EP, seed=0):
    torch.manual_seed(1000+seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=LR,weight_decay=WD)
    yd=((soft-lm.to(device))/ls.to(device)); n=Xin.shape[0]
    fmd=fm.to(device); fsd=fs.to(device); spp=max(1,math.ceil(n/BS)); 
    BS2=64; spp=max(1,math.ceil(n/BS2)); tot=epochs*spp; s=0
    for ep in range(epochs):
        model.train(); perm=torch.randperm(n,device=device)
        for i in range(0,n,BS2):
            ii=perm[i:i+BS2]
            if ii.numel()<2: continue
            xb=(Xin[ii]-fmd)/fsd
            for g in opt.param_groups:
                g['lr']=LR*((s+1)/(15*spp) if s<15*spp else 0.01+0.99*0.5*(1+math.cos(math.pi*(s-15*spp)/max(1,tot-15*spp))))
            opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); s+=1
    return model
BS=32
pe={n:torch.zeros(N) for n in ['pure','aug_j','aug_jr','blend']}
t0=time.time()
for k in range(5):
    te=idx[k::5]; tr=np.setdiff1d(idx,te)
    Xtr2,Ytr2=X[tr],Y[tr]; Xte=X[te]
    fm,fs,lm,ls=compute_norm(Xtr2,Ytr2)
    Tt=[]
    for seed in range(K):
        m,final,_=train_fold(cfg,Xtr2,Ytr2,fm,fs,lm,ls,device,EP,BS,LR,WD,0.0,(False,False,False),0.0,0.0,seed,15,0.01,0.99,None)
        m.load_state_dict(final); Tt.append(m)
    Xtr2d=Xtr2.to(device)
    def ens_pred(Xq):
        with torch.no_grad():
            ps=[predict(m,Xq,fm,fs,lm,ls,device).squeeze(1) for m in Tt]
        return torch.stack(ps).mean(0)
    soft_tr=ens_pred(Xtr2)  # cpu
    # variants
    # pure
    stu=train_student(Xtr2d, soft_tr.to(device), fm,fs,lm,ls); pe['pure'][torch.tensor(te)]=predict(stu,Xte,fm,fs,lm,ls,device).squeeze(1)
    # blend with true
    blend=0.5*soft_tr+0.5*Ytr2.squeeze(1)
    stu=train_student(Xtr2d, blend.to(device), fm,fs,lm,ls); pe['blend'][torch.tensor(te)]=predict(stu,Xte,fm,fs,lm,ls,device).squeeze(1)
    # aug jitter only x3
    Xa=make_aug(Xtr2d,3,0.01,False,seedbase=k); soft_a=ens_pred(Xa.cpu())
    stu=train_student(Xa, soft_a.to(device), fm,fs,lm,ls); pe['aug_j'][torch.tensor(te)]=predict(stu,Xte,fm,fs,lm,ls,device).squeeze(1)
    # aug jitter+reflect x3
    Xb=make_aug(Xtr2d,3,0.01,True,seedbase=k+50); soft_b=ens_pred(Xb.cpu())
    stu=train_student(Xb, soft_b.to(device), fm,fs,lm,ls); pe['aug_jr'][torch.tensor(te)]=predict(stu,Xte,fm,fs,lm,ls,device).squeeze(1)
    print(f'fold{k} done ({time.time()-t0:.0f}s)',flush=True)
for nm,p in pe.items():
    m=metrics(p,Yf); print(f'{nm:8s} rl2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} max={m["maxabs"]:.4f}',flush=True)
PY
nohup python exp7.py > exp7.log 2>&1 &
echo "started $!"
output
started 618
This runs 5 folds × (8 teachers + 4 students) × 400 ep = 5*12*400 = 24000 epochs ≈ 14 min. Plus ensemble predictions on augmented data. Might be ~15 min. Let me wait in chunks. Meanwhile, let me reflect. Actually, let me reconsider the teacher quality. Using EMA 0.99 teachers. K=8. The teacher ensemble on 105 fold-train samples. Good. While waiting, let me reconsider whether I should fold public_test into the FINAL teacher training. For the final model, teachers trained on all 148, student distilled on 148 (+aug). Good. Let me also think: the student in exp7 uses BS2=64 and cosine. The earlier exp6 distill used BS=32. Minor. Let me wait for exp7 partial results. Let me check in ~4 min. While waiting, let me reconsider the primary metric once more and whether distillation could be improved by weighting high-mass samples (to fix regression-to-mean on heavy brackets). Since top-5 samples (heavy) drive 35% of error, and corr(true,err)=-0.5, a loss weighting that upweights heavy samples might reduce the systematic under-prediction. But in distillation, the teacher already captures what's learnable. The student mimicking teacher won't fix teacher's bias. To fix the heavy-bracket under-prediction, I'd need the TEACHERS to predict heavy better. That's a fundamental capacity issue. One more idea to reduce heavy-bracket bias: train teachers with a loss that upweights heavy samples, OR use the linear de-shrink calibration baked into label_std (we saw slope 0.93 → de-shrink by ~1/0.93≈1.075). It gave tiny improvement (0.253→0.2507). Marginal but free and safe. I could apply a mild calibration to the final student: fit slope/intercept on the full training data's OOF... but student trained on all data has no OOF. I could use the teacher ensemble's OOF (CV) slope to set a de-shrink factor baked into label_std. Since it's marginal (<1%) and slightly risky, I'll consider applying a conservative version only if validated. Let me wait for exp7. Let me check progress after some time.
exp7 (distillation variants with augmented transfer sets) will take ~15 min. Let me wait and monitor in chunks.
bash
cd /app/work && sleep 290 && cat exp7.log
output
/app/work/exp7.py:40: UserWarning: Using a target size (torch.Size([64])) that is different to the input size (torch.Size([64, 1])). This will likely lead to incorrect results due to broadcasting. Please ensure they have the same size.
  opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); s+=1
/app/work/exp7.py:40: UserWarning: Using a target size (torch.Size([41])) that is different to the input size (torch.Size([41, 1])). This will likely lead to incorrect results due to broadcasting. Please ensure they have the same size.
  opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); s+=1
/app/work/exp7.py:40: UserWarning: Using a target size (torch.Size([36])) that is different to the input size (torch.Size([36, 1])). This will likely lead to incorrect results due to broadcasting. Please ensure they have the same size.
  opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); s+=1
fold0 done (225s)
There's a broadcasting bug! The student target `yd` has shape [n] (1D) but model output is [n,1]. `F.mse_loss(model(xb), yd[ii])` with model(xb)=[B,1] and yd[ii]=[B] → broadcasting to [B,B]! This corrupts the student training. So exp7's student results are INVALID due to this bug. `soft` passed in is `soft_tr.to(device)` where soft_tr is [n] (1D, from .squeeze(1)). Then yd = (soft - lm)/ls → [n]. yd[ii] → [B]. model(xb) → [B,1]. MSE broadcasts → wrong. I need to fix: make soft targets [n,1]. Let me kill exp7, fix the shape (unsqueeze), and rerun. This bug would make all exp7 student numbers garbage. Good thing there's a warning. Note: exp6's distill used `ens_tr.unsqueeze(1)` → [n,1], and inside distill_student yd=((soft_tr-lm)/ls) → [n,1], and yd[ii] → [B,1]. So exp6 distill was CORRECT (0.259 valid). Only exp7 has the bug. Let me kill and fix exp7.
There's a broadcasting bug in exp7: the soft targets are 1-D `[n]` but the model outputs `[n,1]`, so MSE broadcasts to `[B,B]` , the student results are invalid. (Note exp6's distill used `unsqueeze(1)`, so its 0.259 is valid.) Let me kill exp7, fix the target shape, and rerun.
bash
cd /app/work && kill 618 2>/dev/null; sleep 2; pkill -f exp7.py 2>/dev/null; sleep 1
# fix: make all soft targets [n,1]
sed -i 's/    soft_tr=ens_pred(Xtr2)  # cpu/    soft_tr=ens_pred(Xtr2).unsqueeze(1)  # [n,1]/' exp7.py
sed -i 's/    blend=0.5\*soft_tr+0.5\*Ytr2.squeeze(1)/    blend=0.5*soft_tr+0.5*Ytr2/' exp7.py
sed -i 's/    Xa=make_aug(Xtr2d,3,0.01,False,seedbase=k); soft_a=ens_pred(Xa.cpu())/    Xa=make_aug(Xtr2d,3,0.01,False,seedbase=k); soft_a=ens_pred(Xa.cpu()).unsqueeze(1)/' exp7.py
sed -i 's/    Xb=make_aug(Xtr2d,3,0.01,True,seedbase=k+50); soft_b=ens_pred(Xb.cpu())/    Xb=make_aug(Xtr2d,3,0.01,True,seedbase=k+50); soft_b=ens_pred(Xb.cpu()).unsqueeze(1)/' exp7.py
grep -n "soft_tr=ens_pred\|blend=0.5\|soft_a=ens_pred\|soft_b=ens_pred\|def train_student" exp7.py
output
25:def train_student(Xin, soft, fm,fs,lm,ls, epochs=EP, seed=0):
58:    soft_tr=ens_pred(Xtr2).unsqueeze(1)  # [n,1]
63:    blend=0.5*soft_tr+0.5*Ytr2
66:    Xa=make_aug(Xtr2d,3,0.01,False,seedbase=k); soft_a=ens_pred(Xa.cpu()).unsqueeze(1)
69:    Xb=make_aug(Xtr2d,3,0.01,True,seedbase=k+50); soft_b=ens_pred(Xb.cpu()).unsqueeze(1)
[stderr]
[7]+  Terminated              nohup python exp7.py > exp7.log 2>&1
Now yd = ((soft - lm)/ls) where soft is [n,1], lm/ls are [1] → [n,1]. yd[ii] → [B,1]. model(xb) → [B,1]. Good. Also blend: soft_tr [n,1] + Ytr2 [n,1] → [n,1]. Good. Let me also double-check train_student uses BS2 consistently (it sets BS2=64). And the `spp` uses BS then BS2 reassigns , fine. Let me rerun exp7. It's ~15 min. Let me background and also reduce to fewer epochs? Keep 400 for comparability. Let me rerun. Actually, let me reduce teacher K to 6 to speed up a bit and it's still a good teacher. But I already have exp6 pure-distill=0.259 with K=6. For exp7 let me keep K=8 for consistency within exp7's comparison (all variants use same teachers per fold, so the comparison is fair regardless of K). Let me keep K=8 but maybe reduce epochs to 350 to save time. I'll keep 400 for reliability. Let me just run it. Let me relaunch.
Fixed the target shapes. Let me rerun the distillation-variant comparison.
bash
cd /app/work && nohup python exp7.py > exp7.log 2>&1 &
echo "restarted $!"
output
restarted 654
bash
cd /app/work && sleep 295 && grep -v Warning exp7.log | grep -v mse_loss
output
fold0 done (222s)
~222s/fold × 5 = ~1100s ≈ 18 min. Let me wait more. Let me do a couple of longer sleeps. Let me wait ~5 min chunks. While waiting, let me prepare to also validate the final artifact thoroughly. Let me also think about whether distillation augmentation (aug_j, aug_jr) will help. My hypothesis: teacher-labeled augmented data gives the student more signal and could improve generalization beyond pure distill (0.259). Let me wait for the result. Let me also reconsider: the student uses BS2=64 which differs from teacher BS=32. Minor. Let me wait.
Teachers + 4 students per fold runs ~18 min total. Let me wait for more folds.
bash
cd /app/work && sleep 295 && grep "fold\|rl2" exp7.log
output
fold0 done (222s)
fold1 done (406s)
fold2 done (543s)
bash
cd /app/work && sleep 295 && grep "fold\|rl2" exp7.log
output
fold0 done (222s)
fold1 done (406s)
fold2 done (543s)
fold3 done (725s)
bash
cd /app/work && sleep 220 && grep "fold\|rl2" exp7.log; echo ---; ps aux|grep exp7|grep -v grep|head -1
output
fold0 done (222s)
fold1 done (406s)
fold2 done (543s)
fold3 done (725s)
fold4 done (958s)
pure     rl2=0.2764 mae=0.1682 max=0.7419
aug_j    rl2=0.2814 mae=0.1707 max=0.8187
aug_jr   rl2=0.2728 mae=0.1668 max=0.8809
blend    rl2=0.2682 mae=0.1666 max=0.7953
---
Hmm, exp7 results: - pure: 0.2764 (but exp6 pure distill was 0.2594!) - aug_j: 0.2814 - aug_jr: 0.2728 - blend: 0.2682 These are all WORSE than exp6's distill (0.2594). Why the discrepancy? Differences between exp6 and exp7 distillation: - exp6 student: BS=32, K=6 teachers, train_fold-style cosine (warmup 15), EP=400. - exp7 student: BS2=64, K=8 teachers, custom cosine, EP=400. The main suspect: BS2=64 for student vs BS=32. Larger batch → fewer steps → worse fit for student? Or the custom LR schedule in exp7's train_student differs. Also exp7 uses K=8 teachers (better teacher) yet worse student , so it's the student training (BS64) that's the issue. Also note exp7 "pure" 0.2764 vs exp6 "distill" 0.2594 , notable gap. Likely BS. Let me not trust exp7's absolute numbers due to the BS64 change; the RELATIVE ranking within exp7 (same student setup): blend (0.268) < aug_jr (0.273) < pure (0.276) < aug_j (0.281). So blend (mixing true labels) helps a little; augmentation doesn't clearly help (aug_jr slightly better than pure, aug_j worse). So the distillation-augmentation idea does NOT clearly help. The blend (0.5 true + 0.5 ensemble) helps marginally within exp7's setup. The bigger issue: student BS matters. exp6 used BS=32 for student and got 0.259. Let me revert the student to BS=32 and keep pure/blend, and re-verify. But I'm spending a lot of time. Let me consolidate: What I know works well (exp6, validated correctly): - pure distillation, K=6 teachers (EMA 0.99), student BS=32, cosine, EP=400 → CV rl2=0.2594, mae=0.1639, max=0.7019. That's my best robust single-checkpoint result so far. Let me refine slightly: - Use blend (0.5 true + 0.5 teacher) which helped marginally , but it was tested under BS64. Let me quickly verify blend vs pure at BS=32 in a lighter CV to decide. Actually, blended distillation at BS32 is worth a quick check. But time... Let me make a decision to converge: FINAL PLAN: 1. Teachers: K=8 models trained on ALL 148 data, recipe (bs32, lr1.5e-3, wd2e-2, no-aug, cosine+warmup15, ep500, EMA0.99). 2. Student: distill on the 148 training clouds with targets = blend of (teacher-ensemble, true labels) , but since teachers are trained ON the 148 (including these), their predictions on training data are somewhat overfit (train rl2 ~0.12). Using teacher preds on TRAINING inputs as targets where teachers have low train error ≈ using true labels mostly. Hmm , for the FINAL model, teachers trained on all 148 will predict the 148 training points with low error (they fit them), so soft targets ≈ true labels there. That defeats distillation's smoothing benefit! This is a subtle but crucial point. In CV (exp6), teachers were trained on fold-train and the student was ALSO trained on fold-train, so teacher soft targets on fold-train reflect the teacher's (overfit) fit , but the benefit came because the student mimics the ENSEMBLE's function (averaged), which generalizes better to fold-test. Even though teacher train-preds ≈ overfit, the ensemble AVERAGE of 6 overfit teachers on a training point is still close to true (they all fit it). So why did distillation (0.259) beat single (0.284 expected)? Because: the student trained on ensemble-of-teachers' predictions over training points learns a SMOOTHER function (the ensemble's consensus), and at test time generalizes like the ensemble. Even if soft targets on train ≈ true labels, the student is a fresh model fitting those targets; its generalization ≈ a single model's... but empirically distillation gave 0.259, better than expected single 0.284. Wait, is that just because the student (seed 1000) happened to be a good draw? exp6 ran ONE student (seed 0 → torch.manual_seed(1000)). Could be a lucky student seed! Just like single seed0 was lucky (0.253). Hmm. So exp6's distill=0.259 might also be a lucky single student draw, not a robust property of distillation. This is concerning. Let me think. The student is a single model trained on (nearly-true) soft targets. Its …[truncated]
exp7's absolute numbers are worse than exp6's valid distill (0.259) , the difference is the student's batch size (64 vs 32), so I'll keep BS=32 for the student. The relative ranking shows blending true labels with teacher targets helps slightly; augmentation doesn't. One subtlety for the *final* model: teachers trained on all 148 would overfit those points, making soft targets ≈ true labels and killing the regularization benefit. The principled fix is **cross-fit distillation** , use out-of-fold teacher predictions as the soft targets. Let me implement the full pipeline and validate it honestly on a real held-out split (train on 100, test on the 48 val+public_test samples).
bash
cd /app/work && cat > pipeline.py << 'PY'
import torch, math, numpy as np, time, argparse, os
import torch.nn.functional as F
from lib import *
from cv import train_fold
cfg=load_cfg(); device='cuda'
LR=1.5e-3; WD=2e-2; BS=32; WARM=15; EMAD=0.99

def train_base(X,Y,fm,fs,lm,ls,seed,epochs):
    m,final,_=train_fold(cfg,X,Y,fm,fs,lm,ls,device,epochs,BS,LR,WD,0.0,(False,False,False),0.0,0.0,seed,WARM,0.01,EMAD,None)
    m.load_state_dict(final); return m, final

def train_student(Xin_d, soft_d, fm,fs,lm,ls, epochs, seed):
    # Xin_d,[n,2048,3] on device; soft_d [n,1] on device (mass units)
    torch.manual_seed(2000+seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=LR,weight_decay=WD)
    yd=(soft_d-lm.to(device))/ls.to(device); n=Xin_d.shape[0]
    fmd=fm.to(device); fsd=fs.to(device); spp=max(1,math.ceil(n/BS)); tot=epochs*spp; s=0
    ema=EMA(model,EMAD)
    for ep in range(epochs):
        model.train(); perm=torch.randperm(n,device=device)
        for i in range(0,n,BS):
            ii=perm[i:i+BS]
            if ii.numel()<2: continue
            xb=(Xin_d[ii]-fmd)/fsd
            for g in opt.param_groups:
                g['lr']=LR*((s+1)/(WARM*spp) if s<WARM*spp else 0.01+0.99*0.5*(1+math.cos(math.pi*(s-WARM*spp)/max(1,tot-WARM*spp))))
            opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); ema.update(model); s+=1
    ema.copy_to(model)
    return model

def oof_soft(X,Y,K,epochs,folds=5):
    N=X.shape[0]; idx=np.arange(N); rng=np.random.RandomState(7); rng.shuffle(idx)
    oof=torch.zeros(N)
    for k in range(folds):
        te=idx[k::folds]; tr=np.setdiff1d(idx,te)
        fm,fs,lm,ls=compute_norm(X[tr],Y[tr])
        ps=[]
        for seed in range(K):
            m,_=train_base(X[tr],Y[tr],fm,fs,lm,ls,seed,epochs)
            ps.append(predict(m,X[te],fm,fs,lm,ls,device).squeeze(1))
        oof[torch.tensor(te)]=torch.stack(ps).mean(0)
    return oof  # mass units, [N]

def run_pipeline(Xpool,Ypool,K=6,t_ep=400,s_ep=500,alpha=0.5,s_seed=0):
    fm,fs,lm,ls=compute_norm(Xpool,Ypool)
    oof=oof_soft(Xpool,Ypool,K,t_ep)            # OOF ensemble soft targets
    soft=(alpha*Ypool.squeeze(1)+(1-alpha)*oof).unsqueeze(1)
    stu=train_student(Xpool.to(device),soft.to(device),fm,fs,lm,ls,s_ep,s_seed)
    state={k:v.detach().cpu().clone() for k,v in stu.state_dict().items()}
    return stu,state,(fm,fs,lm,ls),oof

if __name__=='__main__':
    ap=argparse.ArgumentParser(); ap.add_argument('--mode',default='validate')
    ap.add_argument('--K',type=int,default=6); ap.add_argument('--tep',type=int,default=400)
    ap.add_argument('--sep',type=int,default=500); ap.add_argument('--alpha',type=float,default=0.5)
    a=ap.parse_args()
    Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val'); Xte,Yte=load_split('public_test')
    if a.mode=='validate':
        Xpool,Ypool=Xtr,Ytr
        Xhold=torch.cat([Xval,Xte],0); Yhold=torch.cat([Yval,Yte],0)
        t=time.time()
        # baseline singles + ensemble on held-out
        fm,fs,lm,ls=compute_norm(Xpool,Ypool); ps=[]
        for seed in range(a.K):
            m,_=train_base(Xpool,Ypool,fm,fs,lm,ls,seed,a.tep)
            ps.append(predict(m,Xhold,fm,fs,lm,ls,device).squeeze(1))
        singles=[metrics(p,Yhold.squeeze(1))['rel_l2'] for p in ps]
        ens=torch.stack(ps).mean(0)
        print(f'[holdout] single mean={np.mean(singles):.4f} best={min(singles):.4f} worst={max(singles):.4f}')
        print(f'[holdout] ensemble: {metrics(ens,Yhold.squeeze(1))}')
        # distilled student (cross-fit OOF on pool)
        for sseed in range(3):
            stu,state,(fm,fs,lm,ls),oof=run_pipeline(Xpool,Ypool,a.K,a.tep,a.sep,a.alpha,sseed)
            ph=predict(stu,Xhold,fm,fs,lm,ls,device).squeeze(1)
            m=metrics(ph,Yhold.squeeze(1))
            print(f'[holdout] distill sseed{sseed}: rl2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} max={m["maxabs"]:.4f}  (oof-rl2={metrics(oof,Ypool.squeeze
…[truncated]
output
started 707
This validate run: - baseline: K=6 base models on pool (100) = 6×400 = 2400 ep - distill: 3 student seeds, each calls run_pipeline which does oof_soft (5 folds × 6 teachers × 400 = 30×400=12000 ep) + 1 student (500 ep). That's 3×(12000+500) = 37500 ep. Plus baseline 2400. Total ~40000 ep ≈ 23 min. That's expensive because I recompute OOF 3 times (once per student seed). I should compute OOF once and reuse for 3 students. Let me fix to avoid recomputation , but it's already running. Let me kill and optimize: compute OOF once, train 3 students on same OOF. Saves 2×12000 ep. Let me kill and refactor quickly.
That validate run wastefully recomputes the expensive OOF targets for each student seed. Let me fix it to compute OOF once and reuse.
bash
cd /app/work && kill 707 2>/dev/null; pkill -f pipeline.py 2>/dev/null; sleep 2
python - << 'PY'
import re
s=open('pipeline.py').read()
old='''        # distilled student (cross-fit OOF on pool)
        for sseed in range(3):
            stu,state,(fm,fs,lm,ls),oof=run_pipeline(Xpool,Ypool,a.K,a.tep,a.sep,a.alpha,sseed)
            ph=predict(stu,Xhold,fm,fs,lm,ls,device).squeeze(1)
            m=metrics(ph,Yhold.squeeze(1))
            print(f'[holdout] distill sseed{sseed}: rl2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} max={m["maxabs"]:.4f}  (oof-rl2={metrics(oof,Ypool.squeeze(1))["rel_l2"]:.4f})',flush=True)'''
new='''        # distilled student (cross-fit OOF computed ONCE, reused)
        oof=oof_soft(Xpool,Ypool,a.K,a.tep)
        print(f'[pool] oof-ensemble rl2={metrics(oof,Ypool.squeeze(1))["rel_l2"]:.4f}',flush=True)
        for alpha in [0.5,0.0,1.0]:
            soft=(alpha*Ypool.squeeze(1)+(1-alpha)*oof).unsqueeze(1).to(device)
            hs=[]
            for sseed in range(3):
                stu=train_student(Xpool.to(device),soft,fm,fs,lm,ls,a.sep,sseed)
                ph=predict(stu,Xhold,fm,fs,lm,ls,device).squeeze(1)
                hs.append(metrics(ph,Yhold.squeeze(1)))
            rl=[h["rel_l2"] for h in hs]
            print(f'[holdout] distill alpha={alpha}: rl2 mean={np.mean(rl):.4f} (per-seed {[round(x,4) for x in rl]}) mae={np.mean([h["mae"] for h in hs]):.4f} max={np.mean([h["maxabs"] for h in hs]):.4f}',flush=True)'''
s=s.replace(old,new)
open('pipeline.py','w').write(s)
print('patched' if new in s else 'FAILED')
PY
nohup python pipeline.py --mode validate --K 6 --tep 400 --sep 500 > pipe_val.log 2>&1 &
echo "started $!"
output
patched
started 724
[stderr]
[8]+  Terminated              nohup python pipeline.py --mode validate --K 6 --tep 400 --sep 500 --alpha 0.5 > pipe_val.log 2>&1
Now it computes OOF once (12000 ep) + baseline (2400) + 3 alphas × 3 students × 500 = 4500 ep. Total ~19000 ep ≈ 11-12 min. This tests distillation vs baseline on a real 48-sample holdout, and tests alpha ∈ {0.5, 0.0 (pure OOF), 1.0 (pure true labels)}. Note alpha=1.0 means student trained on true labels only = just a normal single model (but trained on pool=100, student seed). This is a good control: distill (alpha<1) vs normal (alpha=1). If alpha=0.5 or 0.0 beats alpha=1.0 on holdout, distillation helps. Let me wait ~5min chunks. Meanwhile, let me reflect on the holdout caveat: val+public_test (48) as holdout. public_test has narrower mass range; val has full range. Combined 48 is a decent holdout. Let me wait.
Now it computes OOF once and tests distillation strength α ∈ {0.5, 0.0, 1.0} (α=1.0 is the normal-training control) with 3 student seeds each, against baseline singles/ensemble , all on the real 48-sample holdout. Let me wait for results.
bash
cd /app/work && sleep 290 && cat pipe_val.log
output
<system>Tool ran without output or errors</system>
bash
cd /app/work && sleep 120 && cat pipe_val.log
output
[holdout] single mean=0.2555 best=0.2319 worst=0.2758
[holdout] ensemble: {'rel_l2': 0.2406313541851859, 'mae': 0.15809277879695097, 'maxabs': 0.5612044334411621}
[pool] oof-ensemble rl2=0.3177
On the real 48-sample holdout (trained on 100): - single mean=0.2555, best=0.2319, worst=0.2758 - ensemble (6 seeds): 0.2406 (rl2), mae 0.158, max 0.561 , ensemble clearly better than mean single here, and much better max! - oof-ensemble on pool = 0.3177 (this is the OOF CV rl2 on the 100 pool; higher because fewer training samples per fold = 80). Interesting , on this holdout, the ENSEMBLE (0.2406) beats the mean single (0.2555) and has a much better worst-case (0.561 vs singles). This differs from the earlier CV where ensemble seemed worse , because earlier "single" was the lucky seed0. Here, ensemble mean over 6 seeds is genuinely better than the average single and has better max. Good , ensembling DOES help on honest holdout. Now waiting for distillation results (alpha=0.5, 0.0, 1.0). The student distilled from the ensemble should approach the ensemble's 0.2406. Let me wait. The oof-ensemble on pool is 0.3177 , but that's from teachers trained on 80 samples (5-fold of 100). The soft targets for distillation are these OOF preds (noisier). The student trained on alpha*true+(1-alpha)*oof. With alpha=0.5, blends true (clean) with OOF (noisy but generalizing). Let me see. Let me wait for the distill lines.
On the honest 48-sample holdout, the 6-seed ensemble (rl2=0.241, max=0.561) clearly beats the average single (0.256) , ensembling genuinely helps, and earlier "single beats ensemble" was just a lucky seed. Now I need the distilled-student lines to see if one checkpoint can capture that ensemble quality.
bash
cd /app/work && sleep 200 && cat pipe_val.log; echo ---; ps aux|grep pipeline|grep -v grep|head -1
output
[holdout] single mean=0.2555 best=0.2319 worst=0.2758
[holdout] ensemble: {'rel_l2': 0.2406313541851859, 'mae': 0.15809277879695097, 'maxabs': 0.5612044334411621}
[pool] oof-ensemble rl2=0.3177
[holdout] distill alpha=0.5: rl2 mean=0.2690 (per-seed [0.2615, 0.2644, 0.2811]) mae=0.1778 max=0.6184
[holdout] distill alpha=0.0: rl2 mean=0.3193 (per-seed [0.3214, 0.3092, 0.3271]) mae=0.2014 max=0.7362
[holdout] distill alpha=1.0: rl2 mean=0.2597 (per-seed [0.2507, 0.2451, 0.2833]) mae=0.1654 max=0.6651
elapsed 518s
---
Holdout results (train on 100, test on 48): - single mean=0.2555 (best 0.232, worst 0.276) - ensemble (6 seeds)=0.2406, mae 0.158, max 0.561 ← BEST - distill alpha=0.5: 0.2690 (worse than single mean!) - distill alpha=0.0 (pure OOF): 0.3193 (bad , OOF targets too noisy) - distill alpha=1.0 (true labels only = normal student): 0.2597 mean (per-seed 0.251, 0.245, 0.283) So distillation (alpha=0.5, 0.0) does NOT help on this honest holdout. alpha=1.0 (normal training, just student seeds) gives 0.2597 mean , same as single baseline. The OOF soft targets (0.3177 quality) are too noisy and DRAG DOWN the student. So cross-fit distillation is NOT helping here , the OOF targets are worse than true labels because teachers trained on only 80 samples generalize poorly. Conclusion: Distillation via OOF is counterproductive. The earlier exp6 distill=0.259 was likely (a) using in-fold teacher preds (which ≈ true on train, so ≈ alpha=1 normal training) and (b) a lucky student seed. So the REAL finding: **the ensemble (prediction averaging) is the best performer (0.2406, max 0.561)**, and no single-checkpoint trick (soup, medoid, distill) reliably reproduces it. A single model averages ~0.256. Given I must submit ONE checkpoint into the fixed architecture, and prediction-ensembling at inference isn't possible... Wait , can I reconsider whether prediction ensembling IS possible at inference? The grader loads my state_dict into ONE PointNetRegressor and calls forward once. I cannot run K models. So no inference ensembling. Confirmed. BUT , the distillation idea is right in principle; the problem was noisy OOF targets from small data. For the FINAL model, if I train teachers on MORE data (all 148) and distill using their predictions on the TRAINING points, those predictions are accurate (teachers fit training well) and the ensemble-average on training points ≈ smooth consensus. The student mimics that. But as shown by alpha=1.0 ≈ alpha=0.5-ish, using true labels directly (alpha=1.0) is about as good. So the best single-checkpoint I can make is just a well-trained single model (alpha=1.0 style), expected rl2 ~0.256 on 48-holdout, and it should be a bit better when trained on all 148. Hmm, but the ensemble gets 0.2406. I really want that. Let me reconsider: is there ANY way to get ensemble-like behavior in one checkpoint? Idea: Distill using teachers trained on ALL 148 (accurate on the 148 training points), with soft targets = ensemble mean on the 148 training points. Since teachers fit training well, soft ≈ true on those points BUT the ensemble mean is slightly smoother. The student trained on these ≈ normal training. Won't capture ensemble's generalization (which comes from averaging DIFFERENT extrapolations on UNSEEN points). The ensemble's advantage is on UNSEEN points where teachers disagree and averaging reduces variance. To distill THAT, the student must see unseen-like points with ensemble labels. That's the augmented-distillation idea , but I need the augmented points to be "unseen-like" and the teacher ensemble to give good labels there. Teachers trained on all 148, evaluated on AUGMENTED (jittered/reflected/interpolated) versions → ensemble averages their disagreement → smoother targets on off-manifold points → student learns the smoothed function → better generalization. exp7 tested augmented distillation but with noisy setup (BS64, OOF). Let me reconsider: the RIGHT augmented distillation for the final: - Teachers trained on ALL 148 (accurate). - Create many augmented clouds (jitter + reflection + maybe mixup/interpolation) from the 148. - Label them with teacher-ensemble predictions (teachers trained on 148, so their preds on augmented clouds reflect ensemble consensus). - Train student on (original 148 with true labels) + (augmented clouds with ensemble soft labels). - Student sees a dense, smoothly-labeled input space → approximates the ensemble function → lower variance. This could work and is the principled way to com …[truncated]
That's a clear, honest verdict: cross-fit OOF distillation *hurts* (OOF targets from 80-sample teachers are too noisy); α=1.0 (normal single-model training) ≈ 0.260, matching the single baseline. The **prediction ensemble is the real winner (0.241, max 0.561)**, but I can't ensemble at inference. The principled way to compress an ensemble into one net is augmented distillation: let teachers (trained on the full data, so accurate) label many augmented clouds, so the student learns the ensemble's *smoothed* function over a dense input space. Let me test this cleanly on the holdout.
bash
cd /app/work && cat > exp8.py << 'PY'
import torch, math, numpy as np, time
import torch.nn.functional as F
from lib import *
from cv import train_fold
cfg=load_cfg(); device='cuda'
LR=1.5e-3; WD=2e-2; BS=32; WARM=15; EMAD=0.99
Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val'); Xpt,Ypt=load_split('public_test')
Xpool,Ypool=Xtr,Ytr
Xhold=torch.cat([Xval,Xpt],0); Yhold=torch.cat([Yval,Ypt],0).squeeze(1)
fm,fs,lm,ls=compute_norm(Xpool,Ypool)

def train_base(X,Y,seed,epochs=400):
    m,final,_=train_fold(cfg,X,Y,fm,fs,lm,ls,device,epochs,BS,LR,WD,0.0,(False,False,False),0.0,0.0,seed,WARM,0.01,EMAD,None)
    m.load_state_dict(final); return m

def aug_copies(Xd, R, jitter=0.008, seedbase=0):
    outs=[]
    for c in range(R):
        g=torch.Generator(device=device).manual_seed(seedbase*131+c+1)
        xb=Xd.clone()
        for a in range(3):
            fl=(torch.rand(xb.shape[0],1,generator=g,device=device)<0.5).float()*(-2)+1
            xb[:,:,a]=xb[:,:,a]*fl
        xb=xb+(torch.randn(xb.shape,generator=g,device=device)*jitter).clamp(-0.025,0.025)
        outs.append(xb)
    return torch.cat(outs,0)

def ens_label(teachers, Xq_d, bs=256):
    ps=[]
    with torch.no_grad():
        for m in teachers:
            m.eval(); out=[]
            for i in range(0,Xq_d.shape[0],bs):
                xb=(Xq_d[i:i+bs]-fm.to(device))/fs.to(device)
                out.append((m(xb).squeeze(1)*ls.to(device)+lm.to(device)))
            ps.append(torch.cat(out))
    return torch.stack(ps).mean(0)  # [n] mass units on device

def train_student(Xin_d, soft_d, epochs, seed):
    torch.manual_seed(3000+seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=LR,weight_decay=WD)
    yd=((soft_d-lm.to(device))/ls.to(device)).unsqueeze(1); n=Xin_d.shape[0]
    fmd=fm.to(device); fsd=fs.to(device); spp=max(1,math.ceil(n/BS)); tot=epochs*spp; s=0
    ema=EMA(model,EMAD)
    for ep in range(epochs):
        model.train(); perm=torch.randperm(n,device=device)
        for i in range(0,n,BS):
            ii=perm[i:i+BS]
            if ii.numel()<2: continue
            xb=(Xin_d[ii]-fmd)/fsd
            for g in opt.param_groups:
                g['lr']=LR*((s+1)/(WARM*spp) if s<WARM*spp else 0.01+0.99*0.5*(1+math.cos(math.pi*(s-WARM*spp)/max(1,tot-WARM*spp))))
            opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); ema.update(model); s+=1
    ema.copy_to(model); return model

t0=time.time()
K=8
teachers=[train_base(Xpool,Ypool,seed) for seed in range(K)]
ens_h=torch.stack([predict(m,Xhold,fm,fs,lm,ls,device).squeeze(1) for m in teachers]).mean(0)
print(f'[holdout] ensemble K={K}: {metrics(ens_h,Yhold)}',flush=True)
Xpool_d=Xpool.to(device)
R=8
Xaug=aug_copies(Xpool_d,R,seedbase=1)
soft_orig=ens_label(teachers,Xpool_d)   # on originals
soft_aug=ens_label(teachers,Xaug)
# variant A: mimic all (orig+aug) with ensemble soft
XA=torch.cat([Xpool_d,Xaug],0); SA=torch.cat([soft_orig,soft_aug],0)
# variant B: orig true + aug soft
SB=torch.cat([Ypool.squeeze(1).to(device),soft_aug],0)
for name,(Xin,S) in {'mimic':(XA,SA),'hybrid':(XA,SB)}.items():
    rs=[]
    for sd in range(3):
        stu=train_student(Xin,S,epochs=300,seed=sd)
        m=metrics(predict(stu,Xhold,fm,fs,lm,ls,device).squeeze(1),Yhold); rs.append(m)
    rl=[r['rel_l2'] for r in rs]
    print(f'[holdout] distill-{name}: rl2 mean={np.mean(rl):.4f} per-seed={[round(x,4) for x in rl]} mae={np.mean([r["mae"] for r in rs]):.4f} max={np.mean([r["maxabs"] for r in rs]):.4f}',flush=True)
print(f'elapsed {time.time()-t0:.0f}s')
PY
nohup python exp8.py > exp8.log 2>&1 &
echo "started $!"
output
started 767
This runs K=8 teachers (3200ep) + 2 variants × 3 seeds × 300ep on ~900 samples. Student on 900 samples: 900/32≈28 steps/ep × 300 = 8400 steps each, ×6 = 50400 steps ≈ maybe 5-7 min. Teachers ~3200 ep on 100 ≈ 2 min. Total ~8-10 min. Let me wait. While waiting, let me reflect on expectations. If mimic/hybrid distillation gets close to ensemble (0.241) and below single (0.256), it's a win. The augmented data gives the student dense coverage so it can replicate the ensemble's smooth function. I'm fairly hopeful for "mimic" since it directly regresses to ensemble outputs over a dense set. Let me wait ~5 min then check. Meanwhile, let me reconsider worst-case metric: ensemble max=0.561 is much better than single max~0.66. If distillation reproduces the smooth ensemble, its max should also improve. Good for the worst-case metric. Let me wait.
exp8 tests whether augmented distillation compresses the ensemble into one checkpoint. Let me wait for it.
bash
cd /app/work && sleep 290 && cat exp8.log
output
[holdout] ensemble K=8: {'rel_l2': 0.24959050765063626, 'mae': 0.15875676025946936, 'maxabs': 0.6111098527908325}
[holdout] distill-mimic: rl2 mean=0.2421 per-seed=[0.2485, 0.2386, 0.2393] mae=0.1576 max=0.6206
Excellent! - ensemble K=8: rl2=0.2496, mae=0.1588, max=0.611 - distill-mimic: rl2 mean=0.2421 (per-seed 0.2485, 0.2386, 0.2393), mae=0.1576, max=0.621 The distilled "mimic" student BEATS the ensemble (0.2421 < 0.2496) and is very stable across seeds (0.239-0.249)! And mae 0.1576 is great. This is a single checkpoint that reproduces (even slightly exceeds) the ensemble. Why does mimic beat the ensemble? Because training on the dense augmented set (900 points) with ensemble labels acts as a strong regularizer producing a smooth function that generalizes slightly better than the raw ensemble, and the augmented reflections add valid invariance. This is the augmented-distillation win. This is a robust, principled single-checkpoint solution. Let me wait for the hybrid result too, then finalize. The key recipe for the final: - Train K teachers on ALL data (148). - Create augmented copies (reflections + jitter). - Label originals+aug with ensemble. - Train student (mimic: all with ensemble soft labels). - Student is the deliverable. Stability across seeds (0.239-0.249) means I don't need to worry much about student seed luck. Let me see hybrid, then build the final. Let me wait a bit.
Strong result: the **distilled "mimic" student beats the ensemble** (rl2=0.242 vs 0.250) and is very stable across seeds (0.239–0.249, mae 0.158) , a single checkpoint that reproduces ensemble quality. The augmented transfer set regularizes it into a smooth, reflection-consistent function. Let me see the hybrid variant, then lock in the final pipeline.
bash
cd /app/work && sleep 150 && cat exp8.log; echo ---; ps aux|grep exp8|grep -v grep|head -1
output
[holdout] ensemble K=8: {'rel_l2': 0.24959050765063626, 'mae': 0.15875676025946936, 'maxabs': 0.6111098527908325}
[holdout] distill-mimic: rl2 mean=0.2421 per-seed=[0.2485, 0.2386, 0.2393] mae=0.1576 max=0.6206
[holdout] distill-hybrid: rl2 mean=0.2452 per-seed=[0.2479, 0.247, 0.2407] mae=0.1578 max=0.6238
elapsed 362s
---
- distill-mimic: 0.2421 (best) - distill-hybrid: 0.2452 Mimic (pure ensemble soft labels on all orig+aug) is slightly better than hybrid. Both beat ensemble (0.2496) and single (0.256). Mimic is the winner and very stable. Now let me finalize. The final pipeline: 1. Train K=8-12 teachers on ALL 148 data (train+val+public_test) with tuned recipe (bs32, lr1.5e-3, wd2e-2, no-aug, EMA0.99, ~400-500 ep). 2. Create augmented copies (R=8: reflections + small jitter 0.008). 3. Ensemble-label originals + augmented. 4. Train student (mimic) on all (orig+aug) with ensemble soft labels, bs32, ~300-400 ep, EMA0.99. 5. BN recalc optional (student is trained normally; BN running stats from EMA should be fine; but let me add recalc on the original 148 normalized for safety). 6. Save student checkpoint with required keys. Let me also increase teacher count for a better ensemble (K=10) and maybe R=10 augmented copies. More teachers → better soft labels. More aug → denser coverage. Let me use K=10, R=10. Training on 148 originals: student sees 148*11=1628 samples. Good. Let me also decide student epochs. On pool(100)*9=900, 300 ep worked. For 148*11≈1628, maybe 250-300 ep is enough (more data per epoch). Let me use 300. Before running the final on all 148 (which I can't evaluate), let me ALSO run one more validate to confirm the full FINAL pipeline (with K=10, R=10, BN recalc, student epochs) on the holdout, to make sure my final code produces the expected ~0.242. Then run the real submission on all 148. Actually, to conserve time/tokens, let me write the final script to be runnable in both 'validate' (train on 100, test on 48) and 'submit' (train on 148, save) modes, sharing the exact same code path. I'll run validate first to confirm, then submit. Let me write final_build.py. Key correctness points: - Shapes: soft labels [n], unsqueeze to [n,1] in student loss. ✓ (I'll handle) - Normalization computed from the POOL (train data used). For submit mode, pool=148, norms from 148. ✓ - feat_mean/feat_std length 3, label_mean/label_std length 1. ✓ - Save state_dict of student. - BN recalc: after EMA copy, reset BN stats and run a few forward passes over the REAL 148 (normalized, no aug) so running stats match the canonical inference distribution. This is important because the student trained on augmented+original mixed data; BN running stats (EMA'd) reflect that mix. At inference, inputs are canonical (non-aug). Recalc on canonical originals makes BN stats match inference distribution. Good idea. But careful: recalc changes the model from its trained state; let me verify it doesn't hurt on holdout. I'll include it in validate to confirm. Let me also verify the saved checkpoint loads into build_model and produces [B,1], and that a full inference reproduces my predict(). Let me write the script with both modes and a thorough self-check. Let me write final_build.py.
Mimic distillation is the winner , stable and beating both the ensemble and single models on all three metrics. Now I'll build the final pipeline (train teachers → augmented ensemble-labeling → mimic student), runnable in `validate` mode (train on 100, test on 48) to confirm the exact code path, and `submit` mode (train on all 148, save checkpoint). Let me write it with a BN-recalibration step and a full checkpoint self-check.
bash
cd /app/work && cat > final_build.py << 'PY'
import torch, math, numpy as np, time, argparse, os
import torch.nn.functional as F
from lib import *
from cv import train_fold
cfg=load_cfg(); device='cuda'
LR=1.5e-3; WD=2e-2; BS=32; WARM=15; EMAD=0.99

def train_base(X,Y,fm,fs,lm,ls,seed,epochs):
    m,final,_=train_fold(cfg,X,Y,fm,fs,lm,ls,device,epochs,BS,LR,WD,0.0,(False,False,False),0.0,0.0,seed,WARM,0.01,EMAD,None)
    m.load_state_dict(final); return m

def aug_copies(Xd,R,jitter,seedbase):
    outs=[]
    for c in range(R):
        g=torch.Generator(device=device).manual_seed(seedbase*131+c+1)
        xb=Xd.clone()
        for a in range(3):
            fl=(torch.rand(xb.shape[0],1,generator=g,device=device)<0.5).float()*(-2)+1
            xb[:,:,a]=xb[:,:,a]*fl
        xb=xb+(torch.randn(xb.shape,generator=g,device=device)*jitter).clamp(-0.025,0.025)
        outs.append(xb)
    return torch.cat(outs,0)

def ens_label(teachers,Xq_d,fm,fs,lm,ls,bs=256):
    ps=[]
    with torch.no_grad():
        for m in teachers:
            m.eval(); out=[]
            for i in range(0,Xq_d.shape[0],bs):
                xb=(Xq_d[i:i+bs]-fm.to(device))/fs.to(device)
                out.append(m(xb).squeeze(1)*ls.to(device)+lm.to(device))
            ps.append(torch.cat(out))
    return torch.stack(ps).mean(0)

def train_student(Xin_d,soft_d,fm,fs,lm,ls,epochs,seed):
    torch.manual_seed(3000+seed)
    model=build_model(cfg).to(device)
    opt=torch.optim.AdamW(model.parameters(),lr=LR,weight_decay=WD)
    yd=((soft_d-lm.to(device))/ls.to(device)).unsqueeze(1); n=Xin_d.shape[0]
    fmd=fm.to(device); fsd=fs.to(device); spp=max(1,math.ceil(n/BS)); tot=epochs*spp; s=0
    ema=EMA(model,EMAD)
    for ep in range(epochs):
        model.train(); perm=torch.randperm(n,device=device)
        for i in range(0,n,BS):
            ii=perm[i:i+BS]
            if ii.numel()<2: continue
            xb=(Xin_d[ii]-fmd)/fsd
            for g in opt.param_groups:
                g['lr']=LR*((s+1)/(WARM*spp) if s<WARM*spp else 0.01+0.99*0.5*(1+math.cos(math.pi*(s-WARM*spp)/max(1,tot-WARM*spp))))
            opt.zero_grad(set_to_none=True); F.mse_loss(model(xb),yd[ii]).backward(); opt.step(); ema.update(model); s+=1
    ema.copy_to(model)
    return model

@torch.no_grad()
def bn_recalc(model,Xorig_d,fm,fs,passes=4,bs=64):
    for m in model.modules():
        if isinstance(m,torch.nn.BatchNorm1d):
            m.reset_running_stats(); m.momentum=None
    model.train(); n=Xorig_d.shape[0]; fmd=fm.to(device); fsd=fs.to(device)
    for _ in range(passes):
        perm=torch.randperm(n)
        for i in range(0,n,bs):
            xb=(Xorig_d[perm[i:i+bs]]-fmd)/fsd
            if xb.shape[0]<2: continue
            model(xb)
    model.eval()

def build(Xpool,Ypool,K,R,t_ep,s_ep,jitter,s_seed,do_bn):
    fm,fs,lm,ls=compute_norm(Xpool,Ypool)
    teachers=[train_base(Xpool,Ypool,fm,fs,lm,ls,seed,t_ep) for seed in range(K)]
    Xpool_d=Xpool.to(device)
    Xaug=aug_copies(Xpool_d,R,jitter,seedbase=1)
    soft=torch.cat([ens_label(teachers,Xpool_d,fm,fs,lm,ls), ens_label(teachers,Xaug,fm,fs,lm,ls)],0)
    Xin=torch.cat([Xpool_d,Xaug],0)
    stu=train_student(Xin,soft,fm,fs,lm,ls,s_ep,s_seed)
    if do_bn: bn_recalc(stu,Xpool_d,fm,fs)
    return stu,(fm,fs,lm,ls),teachers

if __name__=='__main__':
    ap=argparse.ArgumentParser()
    ap.add_argument('--mode',default='validate')
    ap.add_argument('--K',type=int,default=10); ap.add_argument('--R',type=int,default=10)
    ap.add_argument('--tep',type=int,default=450); ap.add_argument('--sep',type=int,default=320)
    ap.add_argument('--jitter',type=float,default=0.008); ap.add_argument('--bn',type=int,default=1)
    ap.add_argument('--sseed',type=int,default=0)
    a=ap.parse_args()
    Xtr,Ytr=load_split('train'); Xval,Yval=load_split('val'); Xpt,Ypt=load_split('public_test')
    t0=time.time()
    if a.mode=='validate':
        Xpool,Ypool=Xtr,Ytr
        Xhold=torch.cat([Xval,Xpt],0); Yhold=torch.cat([Yval,Ypt],0).s
…[truncated]
output
final_build.py ready
started 802
This validate run: K=10 teachers × 450 ep on 100 = 4500 ep (~3 min) + student 320 ep on 100*11=1100 samples (~34 steps/ep × 320 = 11000 steps, ~2 min). Total ~5-6 min. Let me wait. While waiting, let me reflect on whether I should fold public_test into the holdout for validate or not , I did (holdout=val+public_test=48). For submit, I train on all 148. Good. Let me wait for fb_val.log. Let me also think about potential pitfalls with BN recalc with momentum=None (cumulative). reset_running_stats sets running_mean=0, running_var=1, num_batches_tracked=0. With momentum=None, BN uses cumulative moving average over the passes. 4 passes × ceil(100/64)=2 batches = 8 batches. Should give stable estimate. But batch of 64 then 36 (100=64+36). Fine. For submit, 148 → 64+64+20, last batch 20 ≥2 ok. Good. Let me wait.
The final pipeline is running in validate mode (K=10 teachers, R=10 augmented copies, BN recalibration). Let me wait for confirmation that it reproduces the ~0.24 holdout result.
bash
cd /app/work && sleep 290 && cat fb_val.log; echo ---; ps aux|grep final_build|grep -v grep|head -1
output
[VALIDATE] student holdout: {'rel_l2': 0.41698355345042587, 'mae': 0.2844013025363286, 'maxabs': 0.82419753074646}
[VALIDATE] teacher-ens holdout: {'rel_l2': 0.23954458594287345, 'mae': 0.15605160345633826, 'maxabs': 0.6081588268280029}
elapsed 177s
---
The teacher ensemble is great (0.2395), but the STUDENT is terrible (0.417)! Something broke the student compared to exp8 (where mimic student got 0.242). Differences between exp8 and final_build: 1. BN recalc (do_bn=1) , this is NEW and likely the culprit! exp8 had no BN recalc. The bn_recalc with momentum=None and only 4 passes on 100 samples might produce bad running stats, OR resetting BN stats after EMA training broke the model. 2. student epochs: exp8 used 300, here 320. Minor. 3. R=10 vs 8, K=10 vs 8. Minor. The most likely culprit is bn_recalc. Let me check by re-running validate with --bn 0. The student at 0.417 (near mean predictor 0.47... no, 0.417) suggests BN stats are off, making predictions poor. Actually wait , resetting running stats and recomputing on normalized ORIGINAL data should give REASONABLE stats (the canonical inference distribution). But the issue: the student was trained with EMA, and EMAّd BN running stats were consistent with the EMA'd weights. After bn_recalc, I recompute BN stats using the EMA weights in train mode , but the forward pass in train mode uses BATCH stats for normalization while UPDATING running stats. The recomputed running stats should match the data. Unless the problem is that momentum=None cumulative over very few batches with small last batch skews it. Actually, a subtle bug: in bn_recalc I set m.momentum=None which makes BN use cumulative moving average. But I never restore momentum. That's fine for saving. The stats should be the mean/var over all seen batches. With 4 passes, should be okay. Hmm, but the degradation is severe (0.417). Let me just test bn=0 vs bn=1 quickly to isolate. Let me run a quick validate with bn=0 and also bn=1 but more passes. Actually, let me reason: exp8 (no BN recalc, student EMA) gave 0.242. So without BN recalc it works. The BN recalc broke it. Why would recomputing BN stats on the canonical training data hurt? Because during student TRAINING, the data distribution was originals+augmented (reflections change the sign distribution but marginals are symmetric so mean≈0; jitter adds variance). The EMA BN stats reflect that training mix. At inference on canonical data, using training-mix BN stats , exp8 used those and got 0.242 (good). So the EMA BN stats were fine. My recalc REPLACED good stats with bad ones. Possibly the recalc is computing stats on the pre-BN activations correctly, but the issue is that BatchNorm1d running stats recomputed over only ~8 batches with the EMA weights might differ from what the network "expects". Actually no , recomputing on real data should give the true population stats, which is ideal for eval. Wait , maybe the real bug: after EMA .copy_to(model), then bn_recalc resets and recomputes. But .copy_to loads ema.shadow which includes running stats. reset_running_stats zeroes them. Then train-mode forward recomputes. This SHOULD work. Unless... the EMA shadow weights for conv/linear are fine, and recomputed BN stats are fine, so eval should be fine. Let me just empirically test bn=0 to confirm it's the culprit, and if so, drop BN recalc (exp8 proved it's unnecessary , EMA stats work great). Let me run two quick validates: bn=0 (expect ~0.24) to confirm. Let me run bn=0 now. Also reduce to K=8,R=8,sep=300 to match exp8 exactly and confirm reproducibility.
The teacher ensemble is excellent (0.2395), but the student collapsed to 0.417 , the only new element vs exp8 is BN recalibration, so that's the culprit (exp8's EMA BN stats worked fine at 0.242). Let me confirm by re-running with BN recalc off.
bash
cd /app/work && nohup python final_build.py --mode validate --K 8 --R 8 --tep 450 --sep 300 --bn 0 > fb_val_nobn.log 2>&1 &
echo "started $!"; sleep 200; cat fb_val_nobn.log
output
started 828
[VALIDATE] student holdout: {'rel_l2': 0.23931404442075468, 'mae': 0.15064941346645355, 'maxabs': 0.6784389019012451}
[VALIDATE] teacher-ens holdout: {'rel_l2': 0.25038360617358507, 'mae': 0.1629505263020595, 'maxabs': 0.6089205741882324}
elapsed 92s
Confirmed: with bn=0, the student gets 0.2393 (mae 0.1506!, max 0.678), BEATING the teacher ensemble (0.2504). So BN recalc was the bug. I'll drop BN recalc entirely (set bn=0). The EMA BN stats are fine. So the final pipeline (bn=0) gives student holdout rl2=0.239, mae 0.151, max 0.678. Excellent and stable (exp8 showed 0.239-0.249 across seeds). Note: student max (0.678) is a bit higher than ensemble max (0.609). Worst-case is slightly worse than ensemble but rel_l2 and mae are better. To improve worst-case, I could average a couple of student seeds' predictions... but I can only deploy one. Alternatively, I could pick the student seed with the best holdout metrics , but that overfits to the 48 holdout. Since students are stable, I'll just use seed 0 (or try a couple and pick by holdout in validate, but for submit I train on all 148 without holdout). Now, for the SUBMIT model, I train on all 148. I can't evaluate it directly. But the validate run (train on 100, test on 48) confirms the pipeline gives ~0.239. Training on 148 (more data) should be at least as good on the hidden test. Let me reconsider: for the final submit, should I pick the student seed somehow? Students are stable (0.239-0.249), so seed choice matters little. I'll use seed 0. But to be safe and slightly reduce variance, I could do a mini-selection: train 3 student seeds on all 148, and pick the one whose predictions on the 148 best match the teacher-ensemble's predictions on the 148 (i.e., most faithful distillation) , this is leak-free (no true labels of hidden set) and picks the most ensemble-faithful student. Since the ensemble is the stable target, the most faithful student should be the most robust. That's a principled selection. But it adds complexity/time. Given students are already stable, I'll keep it simple but do train 3 seeds and pick the most ensemble-faithful on the augmented+original set (leak-free). Actually simplest robust: pick the student whose training-set predictions are closest to teacher-ensemble labels (lowest distillation loss on the canonical 148). Let me include that. Hmm, but I should be mindful of time/tokens. Let me finalize efficiently: Final submit settings: K=10 teachers (better ensemble), R=10 aug, tep=450, sep=320, bn=0. Train on all 148. For student, train 3 seeds and pick the one with lowest MSE to the ensemble soft-labels on the ORIGINAL 148 (faithfulness), save it. Actually, let me reconsider whether more teachers/aug helps. exp8 K=8,R=8 → 0.239-0.249. validate K=8,R=8 → 0.2393. The ensemble with K=10 (0.2395) vs K=8 (0.2504) , K=10 ensemble was better in the first validate. More teachers → better/smoother soft labels → better student. Let me use K=12 for the final to get the best teacher ensemble. Cost: 12×450=5400 teacher epochs (~4 min) , fine. Let me also reconsider student epochs. Let me keep sep=320. Let me also double check: the student mimic is trained on originals (ensemble-labeled) + augmented (ensemble-labeled). All soft labels. This gave 0.239. Good. Let me add the leak-free student selection (3 seeds, pick most faithful to ensemble on original 148). Let me update the submit branch. Actually, let me reconsider the selection metric. "Most faithful to ensemble on original 148" = lowest MSE(student_pred_on_148, ensemble_soft_on_148). But all students are trained to match ensemble, so all have low faithfulness loss; the one with lowest is most faithful. Since ensemble ≈ good, most faithful ≈ best. This is reasonable and leak-free. But it might just pick the most-overfit-to-train student. Hmm. Since student is trained on ensemble labels (which on originals ≈ teacher consensus), lower train MSE = better fit to consensus = good. I'll use it but it's a minor refinement. Given students are stable, let me keep it simple and robust: train 3 student seeds, AVERAGE would need ensembling (can't). Pick most-faithful. Let me just do it. Let me also verify the final checkpoint thoroughly: load into fresh build_ …[truncated]
Confirmed: BN recalibration was the bug. With it off, the **student hits rl2=0.239, mae=0.151, max=0.678 , beating the teacher ensemble** on the honest holdout. The pipeline is validated. Now let me finalize the submit path: I'll use more teachers (K=12) for smoother soft-labels, and train a few student seeds, selecting the one most faithful to the ensemble (leak-free, since it uses no hidden labels).
str_replace_based_edit_tool
command: str_replace
path: /app/work/final_build.py
old_str:     else:
        Xpool=torch.cat([Xtr,Xval,Xpt],0); Ypool=torch.cat([Ytr,Yval,Ypt],0)
        stu,(fm,fs,lm,ls),teachers=build(Xpool,Ypool,a.K,a.R,a.tep,a.sep,a.jitter,a.sseed,a.bn)
        os.makedirs('/app/outputs',exist_ok=True)
new_str:     else:
        Xpool=torch.cat([Xtr,Xval,Xpt],0); Ypool=torch.cat([Ytr,Yval,Ypt],0)
        # Build teachers + augmented ensemble-labeled transfer set ONCE
        fm,fs,lm,ls=compute_norm(Xpool,Ypool)
        teachers=[train_base(Xpool,Ypool,fm,fs,lm,ls,seed,a.tep) for seed in range(a.K)]
        Xpool_d=Xpool.to(device)
        Xaug=aug_copies(Xpool_d,a.R,a.jitter,seedbase=1)
        soft_orig=ens_label(teachers,Xpool_d,fm,fs,lm,ls)
        soft=torch.cat([soft_orig, ens_label(teachers,Xaug,fm,fs,lm,ls)],0)
        Xin=torch.cat([Xpool_d,Xaug],0)
        print(f'[SUBMIT] teachers={a.K} transfer-set={Xin.shape[0]}',flush=True)
        # Train several students; pick the one most faithful to ensemble on canonical originals
        best=None; best_stu=None
        for sd in range(3):
            stu=train_student(Xin,soft,fm,fs,lm,ls,a.sep,sd)
            p=predict(stu,Xpool,fm,fs,lm,ls,device).squeeze(1).to(device)
            faith=F.mse_loss(p,soft_orig).item()
            print(f'  student seed{sd} faithfulness(MSE to ens)={faith:.5f}',flush=True)
            if best is None or faith<best:
                best=faith; best_stu=stu
        stu=best_stu
        os.makedirs('/app/outputs',exist_ok=True)
output
The file /app/work/final_build.py has been edited successfully.
Now let me run the submit mode. K=12, R=10, tep=450, sep=320, bn=0. This trains 12 teachers (5400 ep) + 3 students on 148*11=1628 samples (51 steps/ep × 320 = 16000 steps each × 3 = 48000 steps). Total maybe ~8-10 min. Let me run it. Wait, but before committing compute, let me reconsider: should the final be trained on all 148 (including public_test)? The hidden test is separate. Yes, using all 148 maximizes data. The validate mode (train on 100) confirmed the pipeline. Submit on 148 should be as good or better. However, I realize there's a subtle concern: in validate I trained teachers on 100 and tested on 48 (val+public_test). For submit I include public_test in training. The hidden test is different from public_test. So no leakage concern. Good. Let me run submit. I'll set bn=0 explicitly. Let me launch and wait.
BN recalc is now disabled by default usage (I'll pass `--bn 0`). Let me run the final submit build on all 148 samples (K=12 teachers, 3 student seeds with faithful selection).
bash
cd /app/work && nohup python final_build.py --mode submit --K 12 --R 10 --tep 450 --sep 320 --jitter 0.008 --bn 0 > fb_submit.log 2>&1 &
echo "started $!"
output
started 857
While this runs (~10 min), let me prepare a thorough verification script that: 1. Loads /app/outputs/model.pt. 2. Reconstructs the model via build_model(cfg), loads state_dict (strict=True). 3. Applies the documented inference transform to a batch and checks output shape [B,1]. 4. Verifies keys and tensor shapes/dtypes. 5. Computes metrics on val and public_test (note: these were used in training the submit model, so this is in-sample; still a sanity check that inference path works and gives sensible numbers). Actually since public_test+val are in the training set of the submit model, metrics will be optimistic. For an honest number I rely on the validate holdout (0.239). But I'll report them as sanity. Let me also double check the checkpoint matches exactly what the grader expects: keys state_dict, feat_mean, feat_std (len 3), label_mean, label_std (len 1). And that model consumes [B,2048,3] → [B,1]. Let me write verify.py now and run it after submit completes. Let me wait and monitor fb_submit.log.
While the final build runs (~10 min), let me write a thorough verification script that mimics exactly what the grader will do: load the checkpoint, reconstruct the fixed architecture, apply the documented normalization transform, and confirm the `[B,2048,3] → [B,1]` contract and all required keys.
bash
cd /app/work && cat > verify.py << 'PY'
import torch, json, sys
from pathlib import Path
sys.path.insert(0,'/app')
from candidate_model import build_model
from lib import load_cfg, load_split, metrics

cfg=load_cfg()
ckpt=torch.load('/app/outputs/model.pt', weights_only=True)
print('keys:', sorted(ckpt.keys()))
for k in ['feat_mean','feat_std','label_mean','label_std']:
    v=ckpt[k]; print(f'  {k}: shape={tuple(v.shape)} dtype={v.dtype} val={v.tolist()}')

# Reconstruct architecture EXACTLY and load
model=build_model(cfg)
missing,unexpected=model.load_state_dict(ckpt['state_dict'], strict=False)
print('missing keys:',missing)
print('unexpected keys:',unexpected)
model.eval()

fm=ckpt['feat_mean'].float(); fs=ckpt['feat_std'].float()
lm=ckpt['label_mean'].float(); ls=ckpt['label_std'].float()

@torch.no_grad()
def infer(X, bs=256):
    outs=[]
    for i in range(0,X.shape[0],bs):
        xb=(X[i:i+bs].float()-fm)/fs          # documented input standardization
        p=model(xb)                            # [B,1]
        outs.append(p*ls+lm)                   # documented de-standardization
    return torch.cat(outs,0)

# contract check
dummy=torch.randn(5,2048,3)
out=infer(dummy)
assert out.shape==(5,1), out.shape
print('contract OK: [5,2048,3] ->', tuple(out.shape))

for s in ['train','val','public_test']:
    X,Y=load_split(s)
    p=infer(X).squeeze(1)
    m=metrics(p,Y.squeeze(1))
    print(f'[{s:11s}] rel_l2={m["rel_l2"]:.4f} mae={m["mae"]:.4f} maxabs={m["maxabs"]:.4f}')
print('NOTE: train/val/public_test are IN-SAMPLE for the submitted model (trained on all 148).')
PY
echo "verify.py ready"; sleep 200; echo "=== submit log so far ==="; cat fb_submit.log
output
verify.py ready
=== submit log so far ===
The submit log is empty after 200s , teachers are still training (12 teachers × 450 ep on 148). Let me wait more.
Teachers are still training. Let me wait for the submit build to progress.
bash
cd /app/work && sleep 180 && cat fb_submit.log; echo ---; ps aux|grep final_build|grep -v grep|head -1
output
[SUBMIT] teachers=12 transfer-set=1628
  student seed0 faithfulness(MSE to ens)=0.00014
---
h2tagent   859 98.5 16.0 22164284 5034540 ?    Rl   23:44   6:27 python final_build.py --mode submit --K 12 --R 10 --tep 450 --sep 320 --jitter 0.008 --bn 0
Teachers done (transfer-set=1628 = 148×11, correct). Student seed0 faithfulness MSE to ensemble = 0.00014 (very faithful). Now training seed1, seed2. Let me wait for them to finish and save. Let me wait ~3 more min.
Teachers done (transfer set = 1628 = 148×11 ✓), and student seed0 is highly faithful to the ensemble (MSE 0.00014). Let me wait for the remaining student seeds and the save.
bash
cd /app/work && sleep 220 && cat fb_submit.log; echo ---; ls -la /app/outputs/ 2>/dev/null
output
[SUBMIT] teachers=12 transfer-set=1628
  student seed0 faithfulness(MSE to ens)=0.00014
  student seed1 faithfulness(MSE to ens)=0.00018
---
bash
cd /app/work && sleep 160 && cat fb_submit.log; echo ---; ls -la /app/outputs/ 2>/dev/null
output
[SUBMIT] teachers=12 transfer-set=1628
  student seed0 faithfulness(MSE to ens)=0.00014
  student seed1 faithfulness(MSE to ens)=0.00018
  student seed2 faithfulness(MSE to ens)=0.00019
[SUBMIT] saved /app/outputs/model.pt
elapsed 649s
---
total 384
drwxr-sr-x 2 h2tagent h2tagent   4096 Jun 20 23:55 .
drwxrws--- 1 h2tagent h2tagent   4096 Jun 20 23:55 ..
-rw-r--r-- 1 h2tagent h2tagent 384685 Jun 20 23:55 model.pt
The model is saved. Seed0 was most faithful (0.00014), so it was selected. Let me verify the checkpoint with verify.py.
The checkpoint is saved (seed-0 selected as most faithful). Let me run the verification script to confirm the checkpoint format, the architecture loads with no missing/unexpected keys, and the inference contract holds.
bash
cd /app/work && python verify.py
output
keys: ['feat_mean', 'feat_std', 'label_mean', 'label_std', 'state_dict']
  feat_mean: shape=(3,) dtype=torch.float32 val=[-0.011780548840761185, 0.007426035590469837, -0.07198601961135864]
  feat_std: shape=(3,) dtype=torch.float32 val=[0.1422092616558075, 0.2692946493625641, 0.08725058287382126]
  label_mean: shape=(1,) dtype=torch.float32 val=[0.7487966418266296]
  label_std: shape=(1,) dtype=torch.float32 val=[0.38614240288734436]
missing keys: []
unexpected keys: []
contract OK: [5,2048,3] -> (5, 1)
[train      ] rel_l2=0.0168 mae=0.0114 maxabs=0.0439
[val        ] rel_l2=0.0196 mae=0.0138 maxabs=0.0407
[public_test] rel_l2=0.0217 mae=0.0141 maxabs=0.0415
NOTE: train/val/public_test are IN-SAMPLE for the submitted model (trained on all 148).
The checkpoint is valid: - All required keys present with correct shapes/dtypes. - No missing/unexpected keys → loads into the fixed architecture cleanly (strict). - Contract: [5,2048,3] → [5,1]. ✓ - In-sample metrics are very low (rel_l2 0.017-0.022) , expected since all 148 are in training. This just confirms the inference path works and the model fits. The honest estimate of hidden-test performance comes from the validate run: rel_l2≈0.239, mae≈0.151, max≈0.678 (trained on 100, tested on 48 unseen). The submit model trained on 148 should be similar or better. Now, let me reconsider whether I can squeeze out more performance, particularly on the worst-case metric (max abs err), which for the student was ~0.678 vs ensemble ~0.61. Also whether the primary rel_l2 can go lower. Given my token budget (~66k), let me consider a few refinements but be judicious: 1. The worst-case: the student's max (0.678) was slightly worse than ensemble (0.609). Earlier, jitter reduced max. But overall student is good. I could potentially reduce worst-case by increasing R (more augmented coverage) or by including a small true-label anchor. But exp8 hybrid (true+aug) had similar max. Probably fine. 2. Actually, let me reconsider: is there a risk my validate holdout (0.239) is optimistic because val+public_test happen to be "easy"? The holdout included the full mass range (val up to 2.07). The single-model CV on 132 was ~0.26-0.28, but that used smaller training folds (105). The validate holdout used 100 train → 48 test and got student 0.239. The submit model uses 148 train. The hidden test is unknown but presumably similar distribution. I'm fairly confident rel_l2 will be in 0.22-0.27 range. Let me see if I can do a slightly more robust estimate: run the validate pipeline with a couple different holdout definitions or seeds to ensure ~0.24 isn't a fluke. But that's expensive. Alternatively, let me reconsider increasing the quality further. The teacher ensemble on 148 is strong. The student mimics it. Key levers for the hidden test: - More teachers (done, K=12). - The student architecture is fixed; its approximation of the ensemble is already faithful (MSE 0.00014). I think the model is solid. Let me do a final robustness check: re-run validate once more with the EXACT submit hyperparameters (K=12,R=10,tep=450,sep=320,bn=0) to get a clean honest estimate matching the submit config. This confirms the number I'd report. Let me run that in background while I consider whether to also test a worst-case-reducing tweak. Actually, I already ran validate with K=10,R=10 (student broke due to BN) and K=8,R=8,bn=0 (student 0.2393). Let me run validate with K=12,R=10,bn=0 to match submit exactly. ~3 min. Let me do it and also, to address worst-case, test whether a tiny bit of jitter in the student's transfer set or a hybrid (anchoring true labels) changes max. But I'll keep it to one confirmation run to save budget. Let me run the matched validate. Actually, let me think about whether to improve the estimate's reliability by averaging over holdout splits. The cleanest: 3-fold "pipeline CV" , but that's 3× the full pipeline (~30 min). Too expensive. I'll do ONE matched validate run (K=12,R=10) to confirm ~0.24, then finalize. Let me also compute, in that run, the per-seed student holdout to confirm stability. Let me modify: I'll just run validate with matched params; it prints student (seed0) and ensemble. Good enough. Let me also quickly add printing of a couple student seeds in validate for stability , but validate currently trains 1 student (sseed=0). That's fine; exp8 already showed stability. Let me just run matched validate. Let me launch it.
The checkpoint is valid: correct keys/shapes, loads into the fixed architecture with no missing/unexpected keys, and satisfies the `[B,2048,3]→[B,1]` contract. In-sample metrics are near-zero as expected. My honest out-of-sample estimate (≈0.239) comes from the validate holdout. Let me run one more validate that exactly matches the submit hyperparameters (K=12, R=10) to confirm that honest number.
bash
cd /app/work && nohup python final_build.py --mode validate --K 12 --R 10 --tep 450 --sep 320 --jitter 0.008 --bn 0 > fb_val_matched.log 2>&1 &
echo "started $!"; sleep 230; cat fb_val_matched.log
output
started 922
[VALIDATE] student holdout: {'rel_l2': 0.24212816182074715, 'mae': 0.15207608478764692, 'maxabs': 0.7100571393966675}
[VALIDATE] teacher-ens holdout: {'rel_l2': 0.2430383884491327, 'mae': 0.1556216273456812, 'maxabs': 0.6154142618179321}
elapsed 218s
Matched validate (K=12,R=10): student holdout rl2=0.242, mae=0.152, max=0.710. Ensemble: 0.243, mae 0.156, max 0.615. Student matches ensemble on rel_l2/mae, slightly worse on max (0.71 vs 0.615). So honest estimate for the submit model (trained on 148, tested on hidden): rel_l2 ≈ 0.24, mae ≈ 0.15. The worst-case ~0.71 is the weakest point. The worst-case metric: the student's max (0.71) is notably worse than the ensemble's (0.615). Since worst-case is a scored metric, and the student's single worst prediction drives it, maybe I can reduce it. Options: 1. Use the teacher ENSEMBLE as the deliverable instead of the student , but I can't deploy an ensemble (only one model). 2. Make the student match the ensemble better on the tails. The student's worst error comes from a specific hard sample. Hmm, but actually , wait. Can I reconsider deploying the ensemble as a single model via a DIFFERENT trick? What if the "student" IS essentially reproducing the ensemble, and its slightly worse max is just student approximation error on one sample. Let me reconsider: is there a way to reduce the worst-case? In exp3, jitter reduced max (0.611 for jit vs 0.8 no-aug) for the base models. For the student transfer set, I already use jitter 0.008 in augmentation. One idea: increase the jitter slightly in the transfer augmentation and/or increase R, to smooth the student further and reduce tail errors. But this might trade rel_l2. Another idea: the worst-case sample , let me check which holdout sample has max error for the student vs ensemble. If it's one specific bracket, maybe nothing to do. Actually, given rel_l2 is the PRIMARY metric and the student matches the ensemble there (0.242 vs 0.243) with better... no, similar mae. The student is a faithful, strong single model. The max being 0.71 vs 0.615 is a minor weakness. Let me consider: would picking the student seed by BEST holdout max be overfitting? Yes. I shouldn't. Alternative to reduce worst-case robustly: train the student with a loss that penalizes large errors more on the transfer set relative to the ensemble , but it's already MSE (penalizes large). Let me reconsider a blend: the student trained on ensemble soft labels might overshoot on some sample where the ensemble was conservative. Actually the ensemble has LOWER max (0.615) , it's more conservative on extremes (averaging pulls toward mean). The student, fitting the ensemble plus augmented data, sometimes extrapolates more → higher max. A hybrid (anchor some true labels) didn't help max in exp8 (0.624). I think 0.24 rel_l2 / 0.15 mae / ~0.71 max is a strong, well-rounded result given the architecture's ceiling (handcrafted features only reached 0.23). The model is validated and robust. Let me make a decision: the current saved model is good. But let me see if I can get a modest improvement on ALL three metrics by increasing the number of teachers further and the transfer set richness, OR by a small ensemble-of-students-in-weight... no. Actually, let me reconsider one more time whether I can reduce variance/worst-case by making the student mimic an even BETTER teacher ensemble. The teacher ensemble quality depends on K and on each teacher's quality. My teachers use the tuned recipe. More teachers (K=16-20) → marginally smoother ensemble. Diminishing returns. Given the strong, validated result and my remaining budget, I'll finalize with the current approach but make one refinement to potentially help the worst-case and robustness: increase K to 16 and R to 12 for the final, and keep everything else. But the current saved model (K=12) already validated at ~0.242. Re-running with K=16 costs ~12 min and may only marginally help. Let me instead do a smarter thing for the FINAL deliverable that I can actually validate: In validate mode, compare student-seed selection strategies and a couple of R/jitter settings to nudge down max without hurting rel_l2, then apply the best to submit. But budget... Let me be decisiv …[truncated]
Matched validate confirms the honest estimate: **student rl2=0.242, mae=0.152, max=0.710** (vs ensemble 0.243/0.156/0.615) , the student matches the ensemble on the primary metric and MAE, with a slightly higher worst-case. This beats even the handcrafted-feature ceiling (0.23 needed full volume computation I can't do at inference). Let me try a richer teacher ensemble (K=16, R=12) to see if it improves, especially the worst-case, before finalizing.
bash
cd /app/work && nohup python final_build.py --mode validate --K 16 --R 12 --tep 450 --sep 340 --jitter 0.008 --bn 0 > fb_val_k16.log 2>&1 &
echo "started $!"; sleep 260; cat fb_val_k16.log
output
started 939
bash
cd /app/work && sleep 60 && cat fb_val_k16.log
output
[VALIDATE] student holdout: {'rel_l2': 0.23942570487417264, 'mae': 0.1514064179112514, 'maxabs': 0.6497652530670166}
[VALIDATE] teacher-ens holdout: {'rel_l2': 0.2481144332720141, 'mae': 0.16143697748581567, 'maxabs': 0.5935226678848267}
elapsed 281s
K=16, R=12: student holdout rl2=0.2394, mae=0.1514, max=0.650. Better than K=12 (0.242/0.152/0.710)! Especially max improved (0.650 vs 0.710) and rel_l2 slightly better (0.2394). The teacher ensemble is 0.2481 (a bit higher due to seed noise), but the student still beats it and has a better max than the K=12 student. So K=16, R=12 gives a slightly better, more robust student (rl2 0.239, mae 0.151, max 0.650). Let me use these settings for the final submit. The improvement on worst-case (0.71→0.65) is worthwhile. Let me re-run submit with K=16, R=12, sep=340, and overwrite /app/outputs/model.pt. This takes ~12 min (16 teachers on 148 + 3 students on 148*13=1924). Let me launch. Actually, let me reconsider sep (student epochs). I used 340 here. The transfer set for submit is 148*13=1924 samples. 340 epochs × (1924/32≈60 steps) = ~20400 steps. Fine. Let me run submit with K=16, R=12, sep=340. While it runs, I'll prepare to re-verify. Let me launch the final submit.
K=16, R=12 improves the student further: **rl2=0.239, mae=0.151, max=0.650** , notably better worst-case than K=12 (0.710). I'll rebuild the final submission with these richer settings, overwriting the checkpoint.
bash
cd /app/work && cp /app/outputs/model.pt /app/work/model_k12_backup.pt && nohup python final_build.py --mode submit --K 16 --R 12 --tep 450 --sep 340 --jitter 0.008 --bn 0 > fb_submit2.log 2>&1 &
echo "started $! (backup of K12 model saved)"
output
started 964 (backup of K12 model saved)
While the final submit runs (~12 min), let me think about whether there's anything else to improve or verify. One concern: the validate estimates (0.239-0.242) come from a single holdout split (val+public_test = 48). To be more confident this isn't a lucky split, I could note that: - exp5 per-seed CV on 132 (5-fold): singles 0.25-0.31, ensemble 0.27. - validate holdout (train 100, test 48): ensemble 0.24-0.25, student 0.239-0.242. The validate holdout gives lower numbers than the 132-CV. This could be because (a) the holdout split is easier, or (b) training on 100 contiguous + testing on a specific 48 differs from 5-fold. The 132-CV averaged over all samples as test (including the hard train samples like idx49, idx119). The holdout 48 might not include the hardest brackets. So the TRUE hidden-test rel_l2 might be somewhere between 0.24 (optimistic holdout) and 0.27 (CV). Realistically ~0.25. Still a strong result and much better than the baseline (~0.47 mean predictor, ~0.33 naive PointNet). Given the hidden test is ~similar size and distribution, and my final model trains on all 148 (more than either estimate's training set), I expect ~0.24-0.26. Good. Is there anything that could go catastrophically wrong at grading? 1. dtype: handled (float32 params, transform promotes). 2. Keys: verified. 3. The grader applies normalization exactly as I do: (x-feat_mean)/feat_std then model then *label_std+label_mean. If the grader instead does something slightly different (e.g., doesn't de-standardize, or expects model to output physical units directly), my model would be wrong. But the task explicitly says these constants are used for standardize/de-standardize, so my assumption matches. My label_mean/std are the standard ones (mean/std of training labels), and the model outputs standardized mass. This is the conventional setup the starter train.py also uses (norm_y then model predicts normalized, saved label_mean/std). So consistent with the provided code. Good. Actually, let me double-check the starter train.py's saved format matches mine: it saves feat_mean/std squeezed (shape [3]) and label_mean/std squeezed. My label_mean/std are reshape(1) → [1]. The starter squeezes label_mean which was [1,1]→ scalar [] maybe. Let me check: starter did label_mean = train.labels.mean(dim=(0,), keepdim=True) → shape [1,1], then .squeeze() → shape []? No, squeeze removes all size-1 dims → scalar tensor shape []. Hmm. The task says "label_mean, label_std - length-1 tensors". So they should be length-1, i.e., shape [1]. My reshape(1) gives [1]. Good , mine is more correct than the starter's squeeze (which gives scalar). But the grader probably handles both via broadcasting. My [1] is safe and matches "length-1 tensors". feat_mean is [3] ("length-3"). Good. Let me wait for submit to finish and re-verify. Let me also reconsider: should label_mean/label_std be float32? Yes, I made them float. Good. Let me wait.
While the final model rebuilds, let me reason about the reliability of my estimate. The single 48-sample holdout (student ≈0.24) is likely a bit optimistic versus the 5-fold CV on 132 samples (ensemble ≈0.27), since the holdout may exclude the hardest brackets. Realistically the hidden-test rel-L2 should land around 0.24–0.26 , well below the mean-predictor baseline (~0.47) and a naive PointNet (~0.33). Let me wait for the build and confirm the checkpoint keys/shapes match the "length-3" and "length-1" spec.
bash
cd /app/work && sleep 280 && cat fb_submit2.log
output
[SUBMIT] teachers=16 transfer-set=1924
Teachers done (transfer-set=1924=148×13 ✓). Students training now. Let me wait more.
bash
cd /app/work && sleep 220 && cat fb_submit2.log; echo ---; ls -la /app/outputs/
output
[SUBMIT] teachers=16 transfer-set=1924
  student seed0 faithfulness(MSE to ens)=0.00030
  student seed1 faithfulness(MSE to ens)=0.00046
---
total 384
drwxr-sr-x 2 h2tagent h2tagent   4096 Jun 20 23:55 .
drwxrws--- 1 h2tagent h2tagent   4096 Jun 20 23:55 ..
-rw-r--r-- 1 h2tagent h2tagent 384685 Jun 20 23:55 model.pt
bash
cd /app/work && sleep 120 && cat fb_submit2.log; echo ---; ls -la /app/outputs/model.pt
output
[SUBMIT] teachers=16 transfer-set=1924
  student seed0 faithfulness(MSE to ens)=0.00030
  student seed1 faithfulness(MSE to ens)=0.00046
  student seed2 faithfulness(MSE to ens)=0.00039
[SUBMIT] saved /app/outputs/model.pt
elapsed 551s
---
-rw-r--r-- 1 h2tagent h2tagent 384685 Jun 21 00:16 /app/outputs/model.pt
The final model (K=16, R=12) is saved. Let me verify this new checkpoint thoroughly.
bash
cd /app/work && python verify.py
output
keys: ['feat_mean', 'feat_std', 'label_mean', 'label_std', 'state_dict']
  feat_mean: shape=(3,) dtype=torch.float32 val=[-0.011780548840761185, 0.007426035590469837, -0.07198601961135864]
  feat_std: shape=(3,) dtype=torch.float32 val=[0.1422092616558075, 0.2692946493625641, 0.08725058287382126]
  label_mean: shape=(1,) dtype=torch.float32 val=[0.7487966418266296]
  label_std: shape=(1,) dtype=torch.float32 val=[0.38614240288734436]
missing keys: []
unexpected keys: []
contract OK: [5,2048,3] -> (5, 1)
[train      ] rel_l2=0.0245 mae=0.0163 maxabs=0.0701
[val        ] rel_l2=0.0222 mae=0.0161 maxabs=0.0411
[public_test] rel_l2=0.0167 mae=0.0098 maxabs=0.0320
NOTE: train/val/public_test are IN-SAMPLE for the submitted model (trained on all 148).
The checkpoint is valid and complete: - All required keys, correct shapes ([3] and [1]), float32. - Loads into fixed architecture with no missing/unexpected keys (strict-compatible). - Contract [B,2048,3]→[B,1] holds. - In-sample metrics tiny (expected). The honest out-of-sample estimate (from matched validate K=16,R=12): rel_l2≈0.239, mae≈0.151, max≈0.650. Now, let me do a final robustness sanity check on the deliverable: test that inference is robust to (a) float16 input (in case grader passes float16), (b) point permutation (PointNet should be permutation invariant , good sanity), and (c) a direct-from-disk float16 load path. Let me verify dtype robustness since data is float16 on disk. Let me test passing float16 features directly through the transform with float32 constants. Also, let me make sure there are no leftover background processes consuming GPU, and clean up. Let me run a robustness check.
The checkpoint is valid and complete. Let me run a few robustness checks on the exact deliverable , float16 inputs (data is float16 on disk), point-order permutation invariance, and a direct float16 transform path , to ensure the grader's inference won't hit surprises.
bash
cd /app/work && python - << 'PY'
import torch, sys
sys.path.insert(0,'/app')
from candidate_model import build_model
from lib import load_cfg, load_split, metrics
cfg=load_cfg()
ck=torch.load('/app/outputs/model.pt', weights_only=True)
model=build_model(cfg); model.load_state_dict(ck['state_dict']); model.eval()
fm,fs,lm,ls=[ck[k].float() for k in ['feat_mean','feat_std','label_mean','label_std']]
X,Y=load_split('val')
@torch.no_grad()
def infer(X):
    return (model((X.float()-fm)/fs)*ls+lm).squeeze(1)
base=infer(X)
# (a) float16 input path
Xh=X.half()
with torch.no_grad():
    ph=(model((Xh.float()-fm)/fs)*ls+lm).squeeze(1)
print('float16-input max abs diff vs float32:', (ph-base).abs().max().item())
# (b) permutation invariance
perm=torch.randperm(2048)
pp=infer(X[:,perm,:])
print('permutation max abs diff:', (pp-base).abs().max().item())
# (c) single-sample batch
s1=infer(X[:1]); print('single-sample vs batched diff:', (s1-base[:1]).abs().max().item())
# (d) model params dtype
print('param dtype:', next(model.parameters()).dtype)
print('val rel_l2 (in-sample sanity):', round(metrics(base,Y.squeeze(1))['rel_l2'],4))
print('ALL ROBUSTNESS CHECKS PASSED')
PY
output
float16-input max abs diff vs float32: 0.0
permutation max abs diff: 0.0
single-sample vs batched diff: 2.086162567138672e-07
param dtype: torch.float32
val rel_l2 (in-sample sanity): 0.0222
ALL ROBUSTNESS CHECKS PASSED
All robustness checks pass: - float16 input: identical (0.0 diff) , the transform casts to float32. - permutation invariance: 0.0 (PointNet is permutation invariant by design). - single-sample batch: negligible diff (2e-7) , BN uses running stats in eval, so batch size doesn't matter. Good. - param dtype float32. The deliverable is solid. Let me clean up background processes and GPU, and do a final summary. Let me kill any lingering processes and confirm the final file is in place. Let me also reconsider: the task mentions writing to /app/outputs/model.pt , done. Good. And NOT to write to /tmp/out , I haven't. Let me make a final check that no stray processes are running and the output file is the K=16 one (timestamp 00:16). Let me also remove the backup model from /app/work (not necessary but tidy). Actually leave it; it doesn't affect grading (only /app/outputs/model.pt matters). Let me do a final listing and summary. Actually, let me reconsider whether I've maximized the primary metric. My honest estimate is ~0.24 rel_l2. I explored: - Hyperparameters (lr, wd, bs, norm): locked at good values. - Augmentation: hurts direct training but the student's transfer augmentation helps. - Ensemble: 0.24-0.25, distilled into single student at 0.239-0.242. - Handcrafted ceiling: 0.23 (but can't deploy). The student at 0.239 is essentially at the ensemble level and near the handcrafted ceiling. Given the fixed architecture and the inference constraint (only linear input normalization, single forward), I believe this is close to optimal. Further gains would be marginal and risk overfitting to the small holdout. I'm confident in the deliverable. Let me finalize with cleanup and a concise summary. Let me verify no background python training jobs remain (to free GPU and avoid any interference).
All robustness checks pass , float16 inputs, point permutation, and single-sample batches all give identical results (BN uses running stats in eval, and PointNet is permutation-invariant by design). Let me clean up background processes and confirm the final deliverable is in place.
bash
cd /app/work && pkill -f final_build.py 2>/dev/null; pkill -f "exp[0-9]" 2>/dev/null; pkill -f pipeline.py 2>/dev/null; sleep 2
echo "=== running python jobs (should be none) ==="; ps aux|grep -E "python (exp|final|pipeline|sweep)"|grep -v grep|head
echo "=== GPU ==="; nvidia-smi --query-gpu=memory.used --format=csv,noheader
echo "=== deliverable ==="; ls -la /app/outputs/model.pt
python -c "import torch; d=torch.load('/app/outputs/model.pt',weights_only=True); print('keys:',sorted(d)); print({k:tuple(d[k].shape) for k in ['feat_mean','feat_std','label_mean','label_std']}); print('n state_dict tensors:',len(d['state_dict']))"
output
=== running python jobs (should be none) ===
=== GPU ===
19173 MiB
=== deliverable ===
-rw-r--r-- 1 h2tagent h2tagent 384685 Jun 21 00:16 /app/outputs/model.pt
keys: ['feat_mean', 'feat_std', 'label_mean', 'label_std', 'state_dict']
{'feat_mean': (3,), 'feat_std': (3,), 'label_mean': (1,), 'label_std': (1,)}
n state_dict tensors: 41
The GPU still shows 19GB used , that might be from the initial 12GB baseline plus residual. Let me check if there are lingering processes. The ps shows none of mine. The 19GB might be other users on a shared GPU (the initial nvidia-smi showed 12.7GB used before I started). So it's not my leftover. Good. Actually, let me double check there are no orphaned python processes holding memory.
bash
ps aux | grep python | grep -v grep | head; echo "--- my python procs ---"; pgrep -af python | grep -iE "exp|final|pipeline|sweep|train" || echo "none of mine running"
output
root        15  0.0  0.0  10960  4708 ?        S    Jun20   0:00 /bin/sh -c /opt/h2t-venv/bin/python3 /mcp_server/server.py
root        16  0.0  0.2 222496 71048 ?        Sl   Jun20   0:00 /opt/h2t-venv/bin/python3 /mcp_server/server.py
h2tagent   335  0.0  0.0 102276 21824 ?        Sl   Jun20   0:00 python -c import sys; sys.path.insert(0, "/tmp/line_io_root_jfzpc"); import runpy; runpy.run_module("line_io_tools._shell_line_io_repl", run_name="__main__", alter_sys=True) --use-redirected-stdin --env null
--- my python procs ---
none of mine running
No lingering training processes of mine. The GPU memory is used by other tenants (shared GPU). The deliverable is in place and verified. Let me write a concise final summary of what I did. But first, let me reconsider if I should leave the train.py as-is or not. The task provided train.py as a starter; I created my own pipeline in /app/work. The deliverable is /app/outputs/model.pt. That's what matters. I don't need to modify the provided files. Actually, let me reconsider one thing: the task says "Keep it as is; your saved checkpoint has to load back into this same architecture." I verified strict load works (no missing/unexpected keys). Good. I didn't modify candidate_model.py. Let me also make sure I haven't accidentally left the model in a bad state. The verify.py loaded it fresh and got sensible in-sample metrics. Good. Let me provide a final summary to the user. I should summarize: - Approach and key findings. - Final method (distillation of ensemble into single checkpoint). - Expected performance. - Deliverable location and format. Let me also record a brief writeup. I think I'm done. Let me do a final confirmation of the model file and give the summary. Actually, let me reconsider whether to also keep a copy of the final training script and document it, in case it's useful. The deliverable is the checkpoint; scripts are in /app/work. That's fine. One more consideration: Should I double-check that the final K=16 model is genuinely better than the K=12 backup, or at least not worse, to decide which to keep? The validate runs showed K=16 student (0.2394/0.151/0.650) better than K=12 student (0.242/0.152/0.710) on the holdout. But these are different holdout-free... no, both validate on the same 48 holdout. K=16 was better on all three. However, the SUBMIT models are trained on 148 (different from validate's 100), and I can't directly evaluate them. The validate comparison (same pipeline, 100→48) is the best proxy, and K=16 won. So keeping the K=16 submit model is the right call. But wait , there's seed randomness between validate and submit. The validate K=16 used tep=450, sep=340; submit K=16 used the same. The student selection picks most-faithful. Should be consistent. I'm confident K=16 is at least as good. I'm done. Let me give the final summary. Let me do a final quick sanity: confirm the exact inference math one more time with a tiny manual example to be 100% sure about the de-standardization direction (output*std+mean, not (output-mean)/std). My predict() does p*ls+lm. The task says "label_mean, label_std - length-1 tensors used to standardize the target during training and de-standardize the model output at inference." Standardize target during training: (y-mean)/std. De-standardize output: out*std+mean. Yes, my direction is correct and matches how I trained (targets were (soft-lm)/ls, model learns to output that, so to recover mass: out*ls+lm). Consistent. Final answer time.
Everything is clean , no lingering processes of mine, and the deliverable is verified and in place. Let me do a final end-to-end confirmation of the exact inference math the grader will use, to be completely certain about the de-standardization direction.
bash
cd /app/work && python - << 'PY'
import torch, sys
sys.path.insert(0,'/app')
from candidate_model import build_model
from lib import load_cfg, load_split, metrics
cfg=load_cfg()
ck=torch.load('/app/outputs/model.pt', weights_only=True)
m=build_model(cfg); m.load_state_dict(ck['state_dict']); m.eval()
fm,fs,lm,ls=[ck[k].float() for k in ['feat_mean','feat_std','label_mean','label_std']]
# Exact grader-style inference: standardize coords -> forward -> de-standardize output
X,Y=load_split('public_test')
with torch.no_grad():
    z = m((X.float()-fm)/fs)          # standardized-space prediction, [B,1]
    mass = z*ls + lm                  # de-standardize to kg
print('sample preds (kg):', [round(v,3) for v in mass.squeeze(1)[:6].tolist()])
print('sample truth (kg):', [round(v,3) for v in Y.squeeze(1)[:6].tolist()])
print('output shape:', tuple(mass.shape), '| positivity: min pred =', round(mass.min().item(),3))
print('\nFINAL DELIVERABLE: /app/outputs/model.pt  (ready)')
print('Honest out-of-sample estimate (train on 100, test on unseen 48):')
print('  rel_L2 ~ 0.239 | MAE ~ 0.151 kg | worst-case abs err ~ 0.65 kg')
PY
output
sample preds (kg): [0.405, 0.563, 0.504, 1.104, 0.714, 0.282]
sample truth (kg): [0.417, 0.549, 0.52, 1.122, 0.733, 0.286]
output shape: (16, 1) | positivity: min pred = 0.202

FINAL DELIVERABLE: /app/outputs/model.pt  (ready)
Honest out-of-sample estimate (train on 100, test on unseen 48):
  rel_L2 ~ 0.239 | MAE ~ 0.151 kg | worst-case abs err ~ 0.65 kg
The deliverable is complete and verified. Here's a summary of what I did and why. ## Outcome I trained a checkpoint at `/app/outputs/model.pt` for the fixed `PointNetRegressor`. It has all required keys (`state_dict`, `feat_mean`/`feat_std` as length‑3, `label_mean`/`label_std` as length‑1), loads into the architecture with **no missing/unexpected keys**, and honors the `[B,2048,3] → [B,1]` contract. Inference is robust to float16 inputs, point permutation, and batch size. **Honest out-of-sample estimate** (trained on the 100-sample train split, tested on the untouched 48 val+public_test brackets): **rel‑L2 ≈ 0.239, MAE ≈ 0.151 kg, worst‑case abs err ≈ 0.65 kg** , versus a mean-predictor baseline of ~0.47 and a naive PointNet of ~0.33. ## How I got there - **EDA / framing.** The data is SimJEB brackets; splits share no bracket IDs, so the model must generalize across geometries. Each cloud is uniformly scaled so its long (Y) axis = 1, centered at the bbox center. I confirmed the task is genuinely nonlinear (best single hand feature correlated only ~0.63) and capacity‑limited: the fixed max‑pool net can't even memorize 100 samples (train rel‑L2 floors ~0.12), so **bias matters as much as variance**. - **Honest validation.** I built a no‑peeking 5‑fold CV harness mirroring the exact grader transform `(x−mean)/std → model → ·std+mean`. This corrected optimistic best-epoch readings on the tiny 32-sample val set. - **Recipe tuning.** Best: per‑axis normalization (beats isotropic), bs=32, lr≈1.5e‑3, **weight decay 2e‑2** (the single biggest lever), cosine schedule, ~450 epochs. Direct augmentation *hurt* here (it worsens underfitting). - **Variance reduction into one checkpoint.** A 6-seed prediction ensemble was stable (~0.24) but can't be deployed at inference; weight "soup" across seeds collapsed to the mean (different basins), and medoid selection was unreliable. The winner was **augmented distillation**: train a teacher ensemble, have it label many reflection+jitter-augmented clouds (mass‑preserving, bbox‑preserving transforms , so the teacher gives consistent free labels), then train a single student to mimic the ensemble over this dense set. The student *matches or beats* the ensemble on rel‑L2/MAE in a single forward pass, and is stable across seeds. - **Final model.** 16 teachers on all 148 labeled samples → ensemble-label originals + 12 augmented copies → distill a student (selected by faithfulness to the ensemble, which is leak-free). Increasing K/R notably improved the worst-case error (0.71→0.65). A couple of honesty notes: the single 48-sample holdout is likely slightly optimistic relative to the full 5-fold CV (~0.26–0.27), so I'd expect the hidden-test rel‑L2 around **0.24–0.26**. I also found that the architecture's true ceiling is real , even an explicit voxel volume estimator only reached ~0.23, and that computation can't be injected at inference since the grader only applies linear coordinate normalization before the fixed forward pass.
Final diff , what the agent changed

No file changes, the agent left the workspace untouched.

Verifier output , the proof behind the reward
tests/test.sh
# SyncVals normalized verifier summary
# task: simjeb-bracket-fea-mass-prediction-real
# attempt: 1  status: completed  raw_score: 0.35
# reward rule: continuous: score 0.35 < 0.5
# => reward = 0.0  (FAIL)
#
# Note: these tasks score via an external hidden grader; no pytest/verifier
# block is embedded in the agent transcript. The block below is the last
# sizable tool-output captured in the run (supporting evidence).
----------------------------------------------------------------------
sample preds (kg): [0.405, 0.563, 0.504, 1.104, 0.714, 0.282]
sample truth (kg): [0.417, 0.549, 0.52, 1.122, 0.733, 0.286]
output shape: (16, 1) | positivity: min pred = 0.202

FINAL DELIVERABLE: /app/outputs/model.pt  (ready)
Honest out-of-sample estimate (train on 100, test on unseen 48):
  rel_L2 ~ 0.239 | MAE ~ 0.151 kg | worst-case abs err ~ 0.65 kg

Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_3face1791423485f. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.

Trial trial_3face1791423485f · verifier authoritative; classifier explanatory.