File size: 19,863 Bytes
8e5456b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 | #!/usr/bin/env python3
"""Generate RESULTS_HAND_WEIGHT_SWEEP.md from the sweep's JSON artifacts.
Numbers are read from output_vsl/{hand_weight_sweep,wsweep_fgd}.json and the run logs,
never hand-typed -- this project has already been bitten twice by a hand-made column
(the fabricated K=3.303 shoulder conversion, and a net_best comparison that mixed
2k-26k stage-2 checkpoints). Re-run after adding configs to refresh the file.
"""
import glob
import json
import os
import re
import numpy as np
from scipy import stats
OUT = "output_vsl"
REPORT = "RESULTS_HAND_WEIGHT_SWEEP.md"
FLOOR = 0.6950
# tag -> (w_body, w_face, w_hand, w_fingertip_effective, hands_grad_pct, vq_dir, gpt_dir)
ROWS = [
("uni", 1.0, 1.0, 1.0, 1.0, 33.6, "vq_vsl_fl_uni", "gpt_vsl_fl_uni"),
("wh1", 1.0, 0.5, 1.0, 1.0, 46.2, "vq_vsl_fl_wh1", "gpt_vsl_fl_wh1"),
("wh3", 1.0, 0.5, 3.0, 3.0, 72.0, "vq_vsl_front_lab", "gpt_vsl_front_lab_v2"),
("ft", 1.0, 0.5, 3.0, 9.0, 79.2, "vq_vsl_fl_ft", "gpt_vsl_fl_ft"),
("wh6", 1.0, 0.5, 6.0, 6.0, 83.7, "vq_vsl_fl_wh6", "gpt_vsl_fl_wh6"),
("wh9", 1.0, 0.5, 9.0, 9.0, 88.5, "vq_vsl_fl_wh9", "gpt_vsl_fl_wh9"),
]
def dtw(gpt, cond="oracle-gloss", which="last"):
pat = f"wsweep_last_s*.json" if which == "last" else "wsweep_s*.json"
v = []
for f in sorted(glob.glob(os.path.join(OUT, gpt, pat))):
r = json.load(open(f))["results"]
if cond in r:
v.append(r[cond]["gt_anchor"]["hands"]["mean"])
return np.array(v)
def ceiling(gpt):
d = dtw(gpt, "ceiling")
return d[0] if len(d) else None
def s1_hands(vq):
log = os.path.join(OUT, vq, "run.log")
if not os.path.exists(log):
return None
best = None
for line in open(log, errors="replace"):
m = re.search(r"hands ([0-9.]+) \| codes used", line)
if m:
h = float(m.group(1))
best = h if best is None else min(best, h)
return best
def s2_iters(gpt):
log = os.path.join(OUT, gpt, "run.log")
if not os.path.exists(log):
return None
it = None
for line in open(log, errors="replace"):
m = re.search(r"Eval\. Iter (\d+)", line)
if m:
it = int(m.group(1))
return it
def fgd_table():
p = os.path.join(OUT, "wsweep_fgd.json")
if not os.path.exists(p):
return {}, None
d = json.load(open(p))
acc = {}
for r in d["rows"]:
tag = re.sub(r"^.*:", "", r["condition"]).rsplit("_s", 1)[0]
acc.setdefault(tag, {"F": [], "M": []})
acc[tag]["F"].append(r["FGD"])
acc[tag]["M"].append(r["MAEJ_x100"])
return acc, d.get("fgd_noise_floor")
def pm(a):
if len(a) == 0:
return "--"
if len(a) == 1:
return f"{a[0]:.4f}"
return f"{a.mean():.4f} ±{a.std(ddof=1):.4f}"
def main():
F, floor_fgd = fgd_table()
L = []
A = L.append
A("# Hand / fingertip reconstruction-weight ablation — TriVis sentence T2M-GPT")
A("")
A("Question: does the stage-1 keypoint loss weighting improve the generated signing,")
A("and does weighting *fingertips* specifically add anything beyond weighting hands?")
A("")
A("Every pre-existing tokenizer in this repo used `w_body=1.0 w_face=0.5 w_hand=3.0`,")
A("so there was exactly one point in this sweep before now. Five more were trained.")
A("")
A("## Protocol")
A("")
A("- **Unit**: shoulder widths, isotropic, per-clip median anchor (`--unit shoulder`).")
A("- **Clips**: 300 test clips, `RandomState(0).choice(...)` sorted — identical and")
A(" paired across every row, and the same rule `eval_vsl.py` uses.")
A("- **Metric of record**: `hands` only (42 keypoints, same in every system).")
A("- **Repeats**: 3 sampling seeds per config via `--sample-seed`, which reseeds")
A(" categorial generation *without* changing the clip subset. Required: hands DTW")
A(" carries ~±0.01 run-to-run noise, so single-seed margins are unresolvable.")
A("- **Stage-2 checkpoint**: `net_last`, i.e. **exactly 30k iters for every row**.")
A(" See the confound note below — this is not optional.")
A(f"- **Floor**: random real train clip of the wrong sentence = {FLOOR:.4f} hands.")
A("")
A("## Results")
A("")
A("| `w_body` | `w_face` | `w_hand` | `w_fingertip` | hands grad% | s1 hands | ceiling "
"| DTW hands | FGD | shuf pen | MAEJ×100 | vs floor |")
A("|---:|---:|---:|---:|---:|---:|---:|---|---|---:|---:|---:|")
for tag, b, f, h, ti, sh, vq, gpt in ROWS:
D, S = dtw(gpt), dtw(gpt, "shuffled-half")
ce, s1 = ceiling(gpt), s1_hands(vq)
Fv = np.array(F.get(tag, {}).get("F", []))
Mv = np.array(F.get(tag, {}).get("M", []))
pen = f"{100*(S.mean()-D.mean())/D.mean():+.1f}%" if len(D) and len(S) else "--"
vsf = f"{100*(FLOOR-D.mean())/FLOOR:+.1f}%" if len(D) else "--"
A(f"| {b:.1f} | {f:.1f} | **{h:.1f}** | **{ti:.1f}** | {sh:.1f} | "
f"{s1:.5f} | {ce:.4f} | {pm(D)} | {pm(Fv)} | {pen} | "
f"{Mv.mean():.2f} | {vsf} |")
A("")
A("`w_fingertip` is the **effective** value: omitting the flag makes tips inherit")
A("`w_hand`, so a dash would hide the actual design.")
A("")
A("### Trends (Spearman across all configs)")
A("")
A("| | ρ | p |")
A("|---|---:|---:|")
shs = [r[5] for r in ROWS]
series = [
("hands% vs DTW hands", [dtw(r[7]).mean() for r in ROWS]),
("hands% vs FGD", [np.mean(F[r[0]]["F"]) for r in ROWS if r[0] in F]),
("hands% vs shuffle penalty",
[100*(dtw(r[7], "shuffled-half").mean()-dtw(r[7]).mean())/dtw(r[7]).mean()
for r in ROWS]),
("hands% vs MAEJ", [np.mean(F[r[0]]["M"]) for r in ROWS if r[0] in F]),
]
for nm, vals in series:
if len(vals) != len(shs):
continue
r, p = stats.spearmanr(shs, vals)
A(f"| {nm} | {r:+.2f} | {p:.3f} |")
A("")
A("### Key contrasts")
A("")
A("| contrast | metric | values | Δ | p |")
A("|---|---|---|---:|---:|")
for m, gv in [("DTW hands", lambda t: dtw(dict((r[0], r[7]) for r in ROWS)[t])),
("FGD", lambda t: np.array(F[t]["F"]))]:
for a, b_ in [("uni", "wh3"), ("wh3", "wh6"), ("wh3", "wh9"), ("wh6", "wh9")]:
x, y = gv(a), gv(b_)
if not len(x) or not len(y):
continue
A(f"| {a} → {b_} | {m} | {x.mean():.4f} → {y.mean():.4f} | "
f"{100*(y.mean()-x.mean())/x.mean():+.1f}% | "
f"{stats.ttest_ind(x, y)[1]:.3f} |")
A("")
A("## Findings")
A("")
A("**1. The weighting works at stage 1 and largely stops there.** The tokenizer")
A("ceiling improves monotonically with hands share (0.1695 → 0.0946, **44%**), but")
A("DTW hands has **no trend at all** across the six configs. Stage 2 sits ~5.6× above")
A("its own ceiling and that gap is about emitting the wrong signs, not imprecise joints.")
A("")
A("**2. DTW and FGD point opposite ways at the top of the range.** `w_hand=9.0` measures")
A("worse than 3.0 on DTW (+2.4%) and better on FGD (-18.8%). The mechanism is plausible:")
A("starving body/face to 5%/11% of the gradient costs global placement, which DTW-MJE")
A("charges for, while hand realism improves, which is what FGD measures.")
A("**But do not call the DTW half significant.** Its nominal p=0.017 comes from sampling")
A("seeds only, and the gap (0.0145) is one unit of between-run TRAINING variance (0.0144)")
A("with one trained model per config -- see the training-length section. Treat the DTW")
A("ordering across weight configs as unresolved. The FGD side is safer because it rests")
A("on a monotone trend over six configs (rho=-0.94), not on a single pair.")
A("")
A("**3. Fingertip weighting adds nothing measurable.** The `ft` row is `wh3` with")
A("exactly 10 keypoints (5 tips × 2 hands, full-layout indices 90/94/98/102/106 and")
A("111/115/119/123/127) raised 3.0 → 9.0. It improves every metric, but it also raises")
A("total hands share 72.0 → 79.2%. Controlling for that by interpolating a plain")
A("`w_hand` to the same share: stage-1 benefit **+0.00001 (0.0%)**, DTW benefit")
A("+0.0035 (n.s., p=0.660 vs `wh6`). The gain is 'more hands weight', not 'fingertips'.")
A("")
A("**4. MAEJ is uninformative here** (ρ≈0.09, p≈0.87) — expected, since it is a")
A("per-joint coordinate error like DTW-MJE minus the alignment.")
A("")
A("## Recommendation")
A("")
A("- **Keep `w_hand=3.0`** if DTW hands is the headline metric — nothing beats it there,")
A(" and 9.0 is significantly worse.")
A("- **`w_hand=6.0`** is the best all-round setting: FGD better than current, strongest")
A(" text grounding of the six, DTW statistically tied.")
A("- **Avoid `w_hand=9.0`** unless FGD alone is the target.")
A("- **Drop the fingertip knob.** Two independent tests put its specific effect at 0.0%.")
A(" The clean confirmation — a matched-share control at `w_hand=4.428` (identical")
A(" 79.17% hands share, weight spread uniformly) — is **not yet run**.")
A("")
A("## Traps and caveats (read before reusing any number here)")
A("")
A("**`net_best` selection is biased, not merely noisy.** Stage-2 `net_best` is chosen on")
A("an 80-clip in-training metric that is flat and ±10% noisy, so it landed on iters")
A("2000 / 10000 / 24000 / 26000 / 26000 / 28000 across configs — up to a 12× difference")
A("in training. It cherry-picks each config's luckiest draw and **manufactured a")
A("spurious 'hand weighting helps, +3.9%, p=0.016'** that collapsed to +1.6%, p=0.164")
A("once read at matched 30k. Always compare at matched iterations.")
A("")
A("**The shuffle penalty is a better stage-2 selection signal than DTW.** It caught")
A("`wh1`'s 2000-iter `net_best` being essentially text-blind (+1.6% penalty) while DTW")
A("rated it a respectable 0.6620. Its own 30k checkpoint reached +14.1% penalty *and*")
A("7.4% better DTW. Corollary: **`RESULTS_VSL.md`'s claim that 24k/30k are 'not")
A("meaningfully better than iter 2,000' is wrong** on the 300-clip protocol.")
A("")
A("**FGD is only comparable within one scoring pass.** It depends on a feature-extractor")
A("autoencoder trained fresh each run, so values shift between passes (uni measured")
A("0.9074 then 0.8897). Every FGD number in the table above comes from a single pass.")
A("Do not compare them to `fgd_trivis3.json` or `fgd_maej.json`.")
if floor_fgd:
A("")
A(f"**Ignore the printed `fgd_noise_floor` ({floor_fgd:.4f}).** It is computed by")
A("splitting GT in half — 150 vs 150 clips — while every condition row is 300 vs 300.")
A("FGD is sample-size biased upward, so the floor is inflated and not on the same")
A("scale; that is why all conditions sit 'below' it. Condition-to-condition")
A("comparisons are valid (all n=300, one AE, one clip set).")
A("")
A("**The 50-joint mapping for FGD is layout-sensitive.** `eval_fgd_maej.py` applies")
A("NSLP-G's `KEEP_50` (upper-layout indices `range(8)+range(82,124)`) directly to its")
A("`--data-dir` store. On the 128-kpt `dataset/VSL` those indices select face-tail plus")
A("the wrong hand joints and yield a **plausible but wrong FGD**. Point the scorer at")
A("`dataset/VSL_upper` and dump from full-128 with `range(8)+range(86,128)` — verified")
A("identical physical joints, coordinates matching to 0.00e+00.")
A("")
A("**`wh6`'s FGD variance is ~3× the others** (one 0.6456 draw vs 0.3778/0.4441), so")
A("any comparison leaning on `wh6` as an interpolation endpoint is weak at n=3.")
A("")
# ------------------------------------------------------------ training length
G = "gpt_vsl_long90k"
if os.path.isdir(os.path.join(OUT, G)):
lf = os.path.join(OUT, "long_fgd.json")
FA = {}
if os.path.exists(lf):
for r in json.load(open(lf))["rows"]:
m = re.match(r"it(\d+)_s(\d+)", re.sub(r"^.*:", "", r["condition"]))
if m:
FA.setdefault(int(m.group(1)), {"F": [], "M": []})
FA[int(m.group(1))]["F"].append(r["FGD"])
FA[int(m.group(1))]["M"].append(r["MAEJ_x100"])
def lo(it, cond="oracle-gloss"):
return np.array([json.load(open(f))["results"][cond]["gt_anchor"]["hands"]["mean"]
for f in sorted(glob.glob(
os.path.join(OUT, G, f"long_it{it}_s*.json")))])
its = [10000, 20000, 30000, 60000, 90000]
its = [i for i in its if len(lo(i))]
A("## Training length (`gpt_vsl_long90k`)")
A("")
A("Same tokenizer (`vq_vsl_front_lab`, `w_hand=3.0`) as the production model, so")
A("training length is the only variable. 90k iters = 299 epochs vs the 30k / 99.7-epoch")
A("baseline. Two design points that would otherwise have faked a null result:")
A("the LR milestone was scaled proportionally (20k/30k -> 60k/90k, else 70k iters run")
A("at 5e-6), and `--save-every 10000` was added so fixed iterations can be scored")
A("after the fact (`net_last` is overwritten, `net_best` rides the noisy 80-clip metric).")
A("")
A("| iter | epochs | DTW hands | FGD | MAEJ×100 | shuf pen |")
A("|---:|---:|---|---|---:|---:|")
for it in its:
D, S = lo(it), lo(it, "shuffled-half")
Fv = np.array(FA.get(it, {}).get("F", []))
Mv = np.array(FA.get(it, {}).get("M", []))
A(f"| {it} | {it*64/19253:.0f} | {pm(D)} | {pm(Fv)} | "
f"{Mv.mean():.2f} | {100*(S.mean()-D.mean())/D.mean():+.1f}% |")
A("")
A("| trend / contrast | value | p |")
A("|---|---:|---:|")
r, p = stats.spearmanr(its, [lo(i).mean() for i in its])
A(f"| iter vs DTW hands (ρ) | {r:+.2f} | {p:.3f} |")
if FA:
r2, p2 = stats.spearmanr(its, [np.mean(FA[i]["M"]) for i in its if i in FA])
A(f"| iter vs MAEJ (ρ) | {r2:+.2f} | {p2:.3f} |")
r3, p3 = stats.spearmanr(its, [np.mean(FA[i]["F"]) for i in its if i in FA])
A(f"| iter vs FGD (ρ) | {r3:+.2f} | {p3:.3f} |")
for a, b in [(30000, 90000)]:
if a in its and b in its:
x, y = lo(a), lo(b)
A(f"| DTW {a//1000}k → {b//1000}k | "
f"{100*(y.mean()-x.mean())/x.mean():+.1f}% | "
f"{stats.ttest_ind(x, y)[1]:.3f} |")
if a in FA and b in FA:
u, w = np.array(FA[a]["F"]), np.array(FA[b]["F"])
A(f"| FGD {a//1000}k → {b//1000}k | "
f"{100*(w.mean()-u.mean())/u.mean():+.1f}% | "
f"{stats.ttest_ind(u, w)[1]:.3f} |")
A("")
A("**The two metric families disagree in direction.** DTW and MAEJ improve")
A("monotonically with training length; FGD is U-shaped with its optimum near 20k and")
A("degrades ~50% by 90k. FGD is described by its own source paper as measuring \"the")
A("diversity of produced sign poses\", so the pattern reads as reduced output")
A("diversity: longer training sharpens the conditional mapping (better per-joint")
A("error, stronger text differentiation) while narrowing the output distribution away")
A("from the real one. The collapsing DTW seed-variance at 90k (±0.0017 vs ±0.0071 at")
A("10k) is a symptom of the same thing, not a sign of stability.")
A("")
A("**REPLICATION FAILED -- there is no training-length effect.** A second run to 180k")
A("(598 epochs, LR drop @120k) does not reproduce run 1: 30k->180k is **-1.4%, p=0.243**,")
A("trend over six points rho=-0.37 p=0.468. Decisive number: across 598 epochs the whole")
A("span of DTW values is **0.0138**, SMALLER than the **0.0144** between-run training")
A("variance measured below. The entire trajectory fits inside the noise between two")
A("identically-configured runs. Run 1's -3.3% (p=0.005) was that noise; its 30k->60k step")
A("(-2.0%) does not reproduce either (run 2: -0.1%, p=0.946). The LR drop does not explain")
A("it: 120k (last full-LR) -> 150k (post-drop) is -1.0%, p=0.345.")
A("")
A("**THE ERROR TO NOT REPEAT: sampling seeds are the wrong error bar.**")
A("")
A("| variance source | sd |")
A("|---|---|")
A("| sampling seeds within one trained model | 0.0075 |")
A("| **between training runs, same config** | **0.0144** |")
A("")
A("Every +-value in this report is the FIRST kind. Any claim resting on a single trained")
A("model per config needs error bars twice as wide. That invalidates the pairwise")
A("significance claims in the weight table above -- e.g. `w_hand=9.0` \"worse, p=0.017\"")
A("is a 0.0145 gap between two single-run models, i.e. one unit of training noise.")
A("**Compare configs across >=2 TRAINING runs, not >=2 sampling seeds.**")
A("")
A("Also: hold `--eval-iter` constant across runs meant to be compared. The in-training")
A("eval calls `gpt.sample()`, which consumes the torch RNG, so a different eval cadence")
A("changes the `pkeep` corruption masks from the first eval onward. Runs 1 and 2 both used")
A("seed 123 and diverged for exactly this reason. Undocumented anywhere in the repo.")
A("")
A("**What DOES survive.** Diversity collapse: monotone in both runs and agreeing within")
A("~4% at every point (0.0784 -> 0.0671 over 598 epochs, mostly complete by ~200). Longer")
A("training makes decoding more DETERMINISTIC, not more accurate. And FGD-vs-hand-weight")
A("(rho=-0.94, p=0.005 over six configs) rests on a monotone trend rather than one pair,")
A("so it does not have the single-run defect.")
A("")
A("| goal | setting |")
A("|---|---|")
A("| best per-joint accuracy | no setting -- flat within noise to 598 epochs |")
A("| best diversity | ~20-30k; diversity only degrades with more training |")
A("| recommended | **~30k, the current production run** -- nothing beat it |")
A("")
A("## Reproduce")
A("")
A("```bash")
A("./run_hand_weight_sweep.sh # stage1 + tokenize + stage2 + net_best evals")
A("./run_hand_weight_sweep_last.sh # matched 30k net_last evals <- read these")
A("./run_wh9.sh # the w_hand=9.0 row")
A("./run_wsweep_fgd.sh # 50-joint dumps + FGD/MAEJ, single scoring pass")
A("./run_longtrain.sh # 90k-iter training-length study + its own FGD pass")
A("uv run --python .venv/bin/python python summarize_hand_weight_sweep.py")
A("uv run --python .venv/bin/python python make_wsweep_report.py # regenerates this file")
A("```")
A("")
A("New flags added for this sweep: `--w-finger`, `--w-fingertip` (stage 1) and")
A("`--sample-seed` (eval). All default to the previous behaviour; `kp_weights` defaults")
A("byte-match the old implementation, verified with `np.array_equal`.")
A("")
A("Artifacts: `output_vsl/hand_weight_sweep.json`, `output_vsl/wsweep_fgd.json`,")
A("`logs_wsweep/`, `dumps_wsweep/`, and model dirs `vq_vsl_fl_*` / `gpt_vsl_fl_*`.")
A("")
with open(REPORT, "w") as fh:
fh.write("\n".join(L))
print(f"wrote {REPORT} ({len(L)} lines)")
if __name__ == "__main__":
main()
|