> **Development log, kept verbatim.** This is the actual NERVE ablation log, > including the local experiment paths and the dead ends, so you can see exactly > what was tested and what the numbers were — not just the final claims. > Section 15 is the capacity sweep that set the 1.80M default. # NERVE — Ablation log & how we landed on the architecture Research log, first pass: **2026-09-05**. Companion to `NERVE.md` (living spec) and `nerve_review.md` (pre-training review). Documents the setup, the exact protocol, results, and every decision — so future maintainers know why NERVE looks the way it does. ## TL;DR (verdicts) | Question | Result | Decision | |---|---|---| | Keep the 7 LayerNorms? | **No — removing them is +1.15 dB** and ~54% faster training | Norm-free is the canonical config | | Dilated convs on blocks 3 & 5? | No gain on bicubic data (23.33 vs 23.39) | Drop for now; re-test under a real-degradation (OTF) recipe | | Conv `bias=True`? | +0.08 dB **with** LN; **0.00 dB** without LN | Keep `bias=False` | | Tanh gate (arm C)? | Not triggered | A already beats SPAN in PSNR | | SiLU / StarReLU (NERVE.md F/G)? | Not run this pass | De-prioritized: marginal-dB chasers vs the simplicity thesis | Headline: **norm-free NERVE (arm D) hit 24.54 dB / 0.724 SSIM on Urban100 x4 under the matched protocol vs 23.39 / 0.566 for the LayerNorm baseline** — and it exports at **30 ONNX nodes instead of 100**. ## 1. Why an ablation at all `NERVE.md` "Planned validation" assumed LayerNorm helped bf16 stability (an educated guess during design, mirroring the fp32-statistics LN fix). The `nerve_review.md` review flagged this as the single highest-value question: LayerNorm is ~37-70% of the exported graph and pure memory-bandwidth cost. The ablations exist to keep only what measurement justifies — the same discipline that produced HEART. ## 2. Environment (this first pass ran on a Windows PC with an Intel Arc) - **Machine:** Windows, Intel Core i3-12100F, 16 GB RAM, **Intel Arc A750 8 GB** (driver 32.0.101.8864). No NVIDIA/CUDA available. - **PyTorch:** `2.14.0+xpu` from `https://download.pytorch.org/whl/xpu` (official Intel-GPU wheels), Python 3.13.1, bf16 autocast, **no torch.compile** (the triton-xpu backend needs a C compiler on PATH on this box). - **Repo:** traiNNer-redux (`train.py` is CUDA-only; untouched). Ablations ran through a self-contained lab in `scripts/nerve/` (`ablation_train.py`, `nerve_variants.py`, `export_dynamic.py`, data + driver scripts). The canonical `traiNNer/archs/nerve_arch.py` stayed frozen during runs. - All arms ran on the **same machine, same backend, same dtype, same seed**, so A-vs-* decisions are internally valid. These numbers are NOT directly comparable to RTX-3060/DIV2K numbers from other runs — record machine/backend/dtype with every run. ## 3. Data & protocol (matched across every arm) - **Train:** LUCID CC0 v2 HC-512 (`Phips/lucid-cc0-v2-hc-512`, public, 100,866 512×512 CC0 tiles). HR kept in `datasets/train/lucid_hc512/hr`; bicubic `lr_x4` generated with **chainner CubicCatrom** (chaiNNer standard, via `chainner_ext`) to match the HEART release recipe. - **Val:** Urban100 x4. LR regenerated locally with the same CubicCatrom kernel as training (downloaded LR packs use a different kernel and shift absolute numbers). Metric = **Y-from-YCbCr, crop_border 4, data range [0,1]**, using traiNNer-redux's `calculate_psnr_pt`/`calculate_ssim_pt` (Matlab-YCbCr convention). Pure-bicubic baseline measured: **23.26 dB / 0.660 SSIM** (published 23.14/0.658 — sanity check passed). - **Per arm:** 20,000 iters, LQ64 crops (GT 256), batch 16, AdamW lr 2e-4 (betas 0.9/0.99, wd 0), linear warmup 1k, MultiStepLR milestone 16k ×0.5, Charbonnier loss (eps 1e-12), EMA 0.999/0.75 (val on EMA net), hflip/rot90, seed 1024, input/GT range **[0,1]** (this matches what traiNNer-redux actually feeds models — SPAN archs assume it). - ~100,866 tiles = ~3.2 epochs at this budget; 16 crops/iter. ## 4. Results (best Urban100 val PSNR @ iter, SSIM at that iter) | Arm | Change vs A | Params | it/s | PSNR | @iter | SSIM | Note | |---|---|---|---|---|---|---|---| | A | baseline (LayerNorm) | 273,840 | 4.1 | 23.387 | 20000 | 0.566 | reference | | D | **norm-free** | 273,168 | 6.4 | **24.541** | 20000 | **0.724** | **winner** | | E | dilation=2 conv2 blocks 3&5 | 273,840 | 7.5 | 23.334 | 20000 | 0.565 | ~tie with A | | F | bias=True (still has LN) | 274,512 | 7.5 | 23.465 | 20000 | 0.566 | +0.08 dB | | B | SPAN (repo `span`, norm=False) | 2,621,964 | 5.5 | 22.526 | 20000 | 0.681 | see caveat | | H | norm-free + bias (partial, 6k iters) | 273,840 | - | 23.76 @6k | - | 0.681 | **= D @6k** → bias adds nothing on norm-free | Reading: - A/E/F all plateaued near the bicubic PSNR (23.3-23.5) with SSIM **below** the bicubic baseline (0.565-0.596 vs 0.660): the LN nets learned an over-smoothed residual under this bf16/EMA protocol. D does not have this problem and improves far faster. - D @ 6k (23.77) already exceeds A's best at 20k (23.39). - H ≈ D exactly at the same iters ⇒ the +0.08 dB bias gain seen on the LN model (F) does not transfer to the norm-free model. Keep `bias=False`. ## 5. Why LayerNorm hurt (hypotheses, for future researchers) Not fully root-caused — the win is empirical and decisive, and the protocol is bf16-autocast + EMA. Candidate mechanisms: 1. LN computes channel statistics per spatial pixel; under bf16 AMP with fp32-cast stats this adds noise/numerical churn per forward. 2. LN re-centers each channel per pixel, which can suppress the small high-frequency residual signals these tiny nets need to emit, biasing the solution toward the bicubic baseline (matching the A/E/F PSNR≈bicubic but SSIM12, +0.09 dB for 12->16 at equal budget), the ~69px RF gives ~4x margin over the recipe's worst 17px blur kernel, and 688K stays in proven-mobile weight class (Real-ESRGAN `compact` is 621K). If a future target phone needs a leaner profile it is a one-constant change, not a variant. NERVE training configs (for the 3060/Linux box) were added mirroring HEART: - `options/train/RELEASE/4x_NERVE_release.yml` (clean bicubic fidelity) - `options/train/RELEASE/4x_NERVE_OTF_fidelity.yml` (OTF fidelity pretrain) - `options/train/RELEASE/4x_NERVE_OTF_gan.yml` (OTF GAN finetune, same losses as 4x_HEART_OTF_gan_lq96) Remaining for the GAN tier (later, on the GPU box): run release -> OTF fidelity -> OTF GAN, validate on Urban100_OTF x4 with TopIQ, then benchmark mobile latency (NCNN/CoreML) and optionally re-test dilation under OTF. ## 10. Multi-scale release chain (2x then 4x-from-2x) — 2026-09-06 EDSR-style cross-scale warm start, validated on this machine (same matched lab protocol; clean bicubic LUCID data; Urban100 val, YCbCr crop_border=scale): | Run | setup | params | iters | best PSNR | SSIM | |---|---|---|---|---|---| | from-scratch 4x (earlier) | random init | 688K | 20k | 24.94 | 0.743 | | **release 2x** | random init | 672K | 60k | **31.19** (x2) | 0.918 | | **release 4x (from 2x)** | 2x body warm start | 688K | 100k | **25.71** | 0.774 | Findings: - The 2x->4x transfer loads 33/34 state-dict keys; only the scale-specific PixelShuffle head (upsampler.0, different channel counts) resets. Warm start is real: 4x-from-2x hit 25.49 dB at only 24k iters vs 24.94 dB at 20k for the random-init run. - Best EMA checkpoints export to dynamic-shape ONNX cleanly (ORT-CPU vs PyTorch max diff ~1e-5 at 48x48/323x711/720x1280). Recipe notes: - Train order: `2x fidelity` -> `4x fidelity (warm body)` -> `OTF fidelity` -> `OTF GAN` (the last two run with traiNNer-redux release configs on the GPU box). - This lab run used lq64/bs16 60-100k iters; the Linux release configs (options/train/RELEASE/2x_NERVE_release.yml, 4x_NERVE_release.yml with the strict_load_g:false warm start) train longer at lq96 and are the canonical release runs. ## 11. Review-2 follow-ups (2026-09-06) Independent review (nerve_review2.md) confirmed the frozen architecture and flagged three blind spots; all cheap ones are now closed with data: ### Fair (param-matched) SPAN baseline — NERVE wins by ~2.1 dB Repo `span` with `feature_channels=26` -> 664,762 params (NERVE is 687,888), identical 20k matched protocol, Urban100 x4: | model | params | best PSNR | SSIM | |---|---|---|---| | SPAN feat26 | 664,762 | 22.798 | 0.663 | | NERVE nb16 (seed 1024) | 687,888 | **24.936** | **0.743** | Even with the parameter budget controlled, repo-SPAN under the identical [0,1]/norm=False protocol sits below the bicubic baseline (23.26 dB) — NERVE leads by ~2.1 dB. Caveat that remains: SPAN's native `norm=True` regime and the GAN/texture tier are still untested; the clean-bicubic claim is now solid, the GAN-tier claim stays queued. ### Seed robustness — size decision is safe Canonical nb16 re-run with seed 2048: 24.917 dB / 0.742 vs seed 1024 24.936 / 0.743 -> run-to-run noise is ~±0.02 dB, far below the +0.09 dB (12->16 blocks) decision margin. ### Multi-dataset eval (release 4x model, standard metric convention) | set | bicubic | NERVE-4x (from 2x, 100k) | from-scratch (20k) | |---|---|---|---| | Set5 | 28.79 | 32.11 | 31.25 | | Set14 | 25.78 | 27.36 | 27.04 | | B100 | 25.69 | 26.39 | 26.28 | | Urban100 | 23.26 | 25.71 | 24.94 | Still queued (need the GPU box / a device): OTF-GAN smoke with LPIPS + eyes on images, dilation-under-OTF, NCNN/CoreML latency, and CoreML dynamic-shape conversion. Toolchain versions should be pinned next to release checkpoints. ## 12. OTF/GAN capability smoke (2026-09-06/07) — the GAN regime now has data Closes the biggest blind spot from `nerve_review2.md` (§4.4/§7.2): every prior NERVE measurement was clean-bicubic fidelity; the Real-ESRGAN OTF + GAN regime had zero measurements. Run on the RTX 3060 box via traiNNer-redux RELEASE configs, **aggressive smoke scale** (lq64, hard caps) — a network test, NOT a release run. **Setup:** stage 1 = 4x OTF fidelity from scratch (no clean checkpoint on this box), lq64, bs8, 20k iters, const lr 1e-4 (`4x_NERVE_OTF_fidelity_lq64.yml`). Stage 2 = 4x OTF GAN, warm start from the stage-1 20k EMA, lq64, bs4, 30k iters (`4x_NERVE_OTF_gan_lq64.yml`) — identical degradation + losses to the proven HEART OTF GAN (mssim 0.5 / perceptual 0.01 / hsluv 1.0 / gan 0.1, dunet D), so any failure would isolate to the generator. Val = Urban100 OTF x4. **Stage 1 (base), PSNR / SSIM / LPIPS:** | iters | PSNR | SSIM | LPIPS | |---|---|---|---| | bicubic ref | 20.55 | 0.533 | 0.737 | | 5k | 21.21 | 0.587 | 0.564 | | 10k | 21.38 | 0.602 | 0.526 | | 15k | 21.43 | 0.608 | 0.505 | | 20k (cut, plateau) | 21.45 | 0.610 | 0.495 | **Stage 2 (GAN), TopIQ / LPIPS:** | iters | TopIQ | LPIPS | |---|---|---| | GAN 5k | 0.350 | 0.432 | | GAN 10k | 0.353 | 0.415 | | GAN 15k | 0.357 | 0.393 | | GAN 20k | 0.360 | 0.383 | | GAN 25k | 0.364 | 0.375 (best LPIPS) | | GAN 30k | 0.365 (best TopIQ) | 0.376 | **Verdict: NERVE is GAN-capable at 688K. No parameter increase needed.** - No collapse/wax signature: TopIQ climbed monotonically to the cap; LPIPS fell 0.12 (−24%) vs the fidelity base. GAN loss crept up slowly (G pressing), D outputs oscillated in a healthy band, structure losses held the base. No NaN. - Visual check (vision model, 8 Urban100 montages, LR/GT/fidelity/GAN): real crisp texture (window grids, planks, perforation holes, rib lines), no grid, no halos, no moiré on regular texture, no color speckle, cleaner than fidelity and close to GT. Trouble cases (dense regular perforation, glass lattice) held up — where small SR nets usually fail. - Absolute TopIQ (0.365) is far below HEART-GAN (0.513): expected — this is a 30k smoke from a 20k scratch base, not a 125k+ warm-started release run. - Implication: the architecture and the shared HEART GAN recipe both hold for the tiny pure-conv member. The remaining work is release-scale training (lq96, warm-started, long schedule), not architecture. **Arch cleanup before official training (2026-09-07):** both temporary test hooks from this pass — the `use_checkpoint` compat param and the `dilate_blocks` knob — were removed from `nerve_arch.py` before any official release run. The release arch is exactly: `NerveBlock` (conv-ReLU-conv), `NERVE(dim=48, n_blocks=16)` with the `nerve()` factory (scale/in_ch/out_ch/dim/n_blocks). 687,888 params at 4x / 672,336 at 2x; 16/16 arch tests; 71-node dynamic ONNX. The lq64 smoke configs were deleted after the test; this log is the record. ## 13. Dilation-under-OTF A/B (arm E re-test, 2026-09-07) — result: see below Arm E was a no-op on clean-bicubic data; the queued follow-up was to re-test `dilation=2` under the OTF recipe, where real blur lives. Protocol is IDENTICAL to the §12 stage-1 baseline (from scratch, lq64, bs8, 20k, const lr 1e-4); dilated arm adds `dilate_blocks=[7, 11]` (dilation=2 on conv2 of blocks 8 & 12, one-indexed) — zero extra params. Decision rule: adopt only on a clear win (>~0.1 dB OTF-val PSNR, seed noise ≈ ±0.02); otherwise freeze plain. Expectation was null (RF ~71 px is ~4x the worst 17 px blur kernel). **Exportability (verified before the run):** the dilated variant exports to dynamic-shape ONNX identically to plain — same 71 nodes, same op set (Conv/Relu/Add/DepthToSpace/Resize), ORT-CPU parity ~3.4e-6 at 64x64 and 77x83. Dilation appears only as the standard Conv `dilations=[2,2]` attribute, which NCNN supports natively. If dilation ever wins, deployability is unaffected. **Result (2026-09-07): NULL — freeze the plain architecture.** | arm | 20k PSNR | SSIM | LPIPS | |---|---|---|---| | plain baseline (§12) | 21.454 | 0.6102 | 0.4954 | | dilated [7,11] | 21.462 | 0.6111 | 0.4958 | Dilated trails/ties plain at every checkpoint (5k +0.007, 10k +0.006, 20k +0.008 dB PSNR; LPIPS 0.4958 vs 0.4954) — well inside the ±0.02 seed band. The RF (~71 px ≈ 4x the worst 17 px blur kernel) already covers OTF blur, so extra dilated reach has nothing to add. **Architecture decision: NERVE is frozen as canonical plain norm-free (dim 48, n_blocks 16, bias=False), no dilation.** Dilation was removed from the arch file before official training (see the §12 cleanup note); the A/B table above is the record of why. ## 14. Checkerboard / phase-lock fix (2026-09-10) — ICNR + degradation phase jitter **Problem (visual):** the OTF **fidelity** stage output (and, to a lesser degree, the OTF **GAN**) shows a fine 2x2 checkerboard + period-4 column banding on smooth dark regions (anime hair, flat sky). The clean `*_release` models are free of it (const-gray 2x2 phase spread 0.003/255 vs fidelity 0.169/255 at 135k). It is baked into the head weights, not a sampling glitch: constant-gray input produces a periodic output signature. **Root causes identified:** 1. **Phase-locked degradation.** The final resize-back in `RealESRGANModel` resamples to the fixed lq grid, so the LR always sits at the same sub-pixel phase relative to the GT. The model exploits that lock, and the PixelShuffle head learns a phase-dependent DC gain -> checkerboard. 2. **`nearest-exact` in the final resize-back** (`resize_mode_list3`, 25% probability in the release OTF configs). Nearest is phase-locked *and* aliasing — the single worst case for the grid. 3. **No ICNR init.** The PixelShuffle conv was randomly initialized, so each sub-pixel phase starts with an independent bias. **Fixes (merged, permanent):** - `traiNNer/utils/icnr.py`: ICNR init + a `icnr_reinit(model)` walker that re-initializes every Conv2d directly feeding a PixelShuffle. - `nerve_arch.py` / `heart_arch.py`: **ICNR is now unconditional** — applied automatically at construction in both arches, no flag. It is init-only (zero graph/runtime cost, same dynamic-ONNX output) and is the same practice `temporal_span_arch.py` (SPAN) already uses. - `redux_options.py`: `path.icnr_reinit_g` (re-scrub the head after loading a warm-start, keeping the body) and `lq_phase_jitter` / `lq_phase_jitter_range` (random sub-pixel shift of the final LQ, train-only). **`lq_phase_jitter` was briefly defaulted to true, then reverted to false in §15** — jitter destabilized the GAN stage and aliased LR is the intended teacher. - `realesrgan_model.py`: `_lq_phase_jitter` — random (dx,dy) in ±0.5 lq px via grid_sample (reflection padding), applied after the final sinc filter. - `misc.py`: `icnr_reinit` excluded from `make_exp_dirs` path iteration. - New configs `4x_NERVE_OTF_fidelity_v2.yml` / `4x_NERVE_OTF_gan_v2.yml`: `icnr_reinit_g: true` (fidelity only) and anti-aliased `resize_mode_list3` (bilinear/bicubic/lanczos, no nearest-exact). (Arch `icnr` key is no longer a config option — the arch just does it.) - `4x_NERVE_L_OTF_fidelity.yml` / `4x_NERVE_L_OTF_gan.yml`: same recipe at dim 64 / n_blocks 24 (1.80M params) for the mottling re-test, from scratch. **Baseline grid metrics (const-gray, /255; ani-hair 2x2 chkr in parens):** | model | checkerboard 2x2 | x period-4 | |---|---|---| | 4x release 485k | 0.003 | 0.014 | | 4x OTF fidelity v1 135k | 0.169 | 0.127 | | 4x OTF GAN v1 35k | 0.045 | 0.127 | Target for v2 fidelity: const-gray 2x2 phase spread << 0.10 (near release levels). Validation in progress (`scripts/nerve/check_grid.py`). **Eval note:** v2 fidelity warms up from the clean release weights with an ICNR-scrubbed head, so its head starts phase-balanced. **Result: the degradation changes that accompanied this (phase jitter + anti-aliased final resize) made the GAN visibly worse — see §15.** ## 15. Capacity scaling + the aliasing lesson (2026-09-12/13) **What went wrong in v2 (result).** The v2 chain (weaker base + sub-pixel `lq_phase_jitter` + anti-aliased final resize, no nearest-exact) did reduce the smooth-hair grid mid-training (const-gray 0.169 -> 0.058 @95k) but at a real cost: - fidelity PSNR 21.95 (v1) -> 21.23 (v2) on the phase-locked val (~-0.3 dB on a fair sub-pixel-shifted eval, where v1 loses 2.4 dB under shift vs v2's 1.0). - the GAN v2 output is visibly **worse**: thin lines break into beading/dots and flat areas stipple. Line high-freq (right-hair LapStd): v1 GAN 35k = 19.31, v2 GAN 45k = 24.23 (+25%), v2 60k = 25.53. lpips 0.3174 -> 0.4020, topiq 0.4065 -> 0.3485. - the fidelity grid crept back late (0.149 @200k). **The aliasing lesson (the fix).** *The LR must stay aliased.* `nearest-exact` downscaling gives jagged LR; training on it teaches the aliased->smooth (de-alias) mapping. Removing aliasing from the LR (anti-aliased final resize) plus sub-pixel jitter left the generator phase-uncertain and unable to de-alias, so adversarial pressure turned thin lines into beading. Actions: - `resize_mode_list3` restored to include `nearest-exact` in all NERVE OTF configs. - `lq_phase_jitter` default reverted to **false**. - ICNR (init-only) kept — harmless and correct. **Capacity scaling (exact v1 GAN recipe; aliased LR; no jitter).** The bigger model is warm-started from the v1 dim48 fidelity via a width+block expansion (`scripts/nerve/expand_nerve.py`: old weights top-left, new channels small random, new blocks near no-op) — verified ~equivalent to v1 fidelity (81.85 dB). | model | params | topiq @35k | lpips @35k | line HF right/bangs | |---|---|---|---|---| | v1 GAN dim48/b16 | 0.69M | 0.4065 | 0.3174 | 19.31 / 15.54 | | dim64/b24 | 1.80M | **0.4124** | **0.3136** | **18.64 / 14.82** | | dim64/b24 @40k | 1.80M | 0.4121 | 0.3122 | 19.01 / 15.13 | Capacity **helps both metrics and line cleanliness** (lower HF = fewer artifacts); the 1.80M model had not plateaued at 40k (lpips still falling) while v1 plateaued at 35k. **Reference parameter counts:** SRVGGNetCompact default 0.62M, x4v3 1.21M, ultracompact 0.33M; SPAN-S 2.24M, SPAN base 2.62M, SPAN-F64 3.96M. NERVE 1.80M sits in the SPAN class while staying conv-only. **Protocol (running):** train 1.80M to 150k, then dim64/b32 (2.39M) to 150k with the same recipe/start/iters -> scaling curve. Next point if needed: dim96/b24 (4.03M). Deferred: a dedicated aliasing augmentation in the OTF pipeline; WebP compression (jpeg already generalizes to webp/screenshot DCT).