Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0900a4c
docs(research): record TODO(nowcast) decoding-scheme plan in lstm dee…
jajupmochi Jun 12, 2026
b0826c0
docs: add task ledger (docs/tasks.md) — completed / in-progress / future
jajupmochi Jun 12, 2026
412f531
style: wrap three over-long comment lines (CI ruff E501)
jajupmochi Jun 12, 2026
0d89607
docs: task ledger moved to liulian-docs hub; keep pointer stub
jajupmochi Jun 12, 2026
6900e19
style: ruff-format entity_mixin.py (CI format check)
jajupmochi Jun 12, 2026
6f35a4a
feat(optim): make data-layer dims genuinely tunable on per_entity
jajupmochi Jun 12, 2026
8611acd
feat(models): unify transparent identifiers behind EntityTransparentW…
jajupmochi Jun 12, 2026
15faedd
docs(research): swiss3dt 6-mode results + coordinates-fix writeup
jajupmochi Jun 13, 2026
7437a80
docs: retract 2026-05 coordinates results (task #29) + fix comparison…
jajupmochi Jun 13, 2026
fdecec1
fix(mc): wire transparent identifiers + coords + NaN for swiss dlinea…
jajupmochi Jun 14, 2026
82f0903
fix(mc): rebuild ChannelTransparentWrapper from the model, not config
jajupmochi Jun 14, 2026
59f44bb
docs(research): swiss mc dlinear/patchtst results + research-critic a…
jajupmochi Jun 14, 2026
20986f8
perf(mc): algebraic ChannelTransparentWrapper._augment (fixes onehot …
jajupmochi Jun 14, 2026
97c89c7
docs(research): entity-id summary figures (heatmap + LaTeX table)
jajupmochi Jun 15, 2026
fb94724
docs(research): mark per-(model,dataset) best id-mode in summary table
jajupmochi Jun 15, 2026
6eabbda
docs(research): PatchTST transparent failure = instance-norm cancels …
jajupmochi Jun 15, 2026
bead95c
feat(figures): pre-build ablation table scaffold for patchtst injecti…
jajupmochi Jun 15, 2026
e428fc1
feat(patchtst): transparent add_after_patch (task #40, integrated fro…
jajupmochi Jun 15, 2026
66a5a80
fix(hpo): stable Ray experiment name when resuming (cross-walltime re…
jajupmochi Jun 16, 2026
302e93d
feat(run_job): --patchtst-transparent-add-after-patch flag (ablation …
jajupmochi Jun 16, 2026
579b2b9
chore(cost): log June ablation paygo jobs (projected ~3.6 CHF); wire …
jajupmochi Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ Layer → directory map: `tasks/`, `data/`, `models/` (ABC), `adapters/` (wrappe

## Conventions worth knowing up-front

- **Task ledger:** lives in the `liulian-docs` hub repo at
`docs/tasks/liulian-python/<YYYY-MM>.md` (monthly files, topic-tagged
entries; `docs/tasks.md` here is just a pointer stub). Update the ledger
whenever a work item finishes or a new one is decided.
- **Optional deps:** core is numpy + pyyaml only. Torch, ray, wandb, mkdocs are all extras. When importing an optional dep in library code, catch `ImportError` and raise with install hint (`pip install -e '.[logging]'`).
- **Datasets go through manifests.** `manifests/*.yaml` defines fields/topology/integrity hash; loaders in `liulian/data/` read from these, not raw paths.
- **Experiments live in `experiments/<dataset>/`** with their own config YAML + runner. They're separate from `tests/`.
Expand Down
37 changes: 35 additions & 2 deletions docs/research/2026-05-15-entity-id-progress-slide.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

_Advisor update · 2026-05-15 · single-slide summary (+ speaker notes)_

> ⚠️ **Correction (2026-06-13) — `coordinates` rows are RETRACTED.**
> This snapshot's `coordinates` results (the `🔴 +4.1 %` LSTM cell in §2,
> its heatmap column, and §5.1) are **invalid**: a bug meant the coordinate
> identifier was fed an
> all-**zero vector** (the dataset topology that holds station `(x, y)` was
> never loaded under the matrix default `graph_mode='none'`, so
> `make_entity_features` silently zero-filled — measured `abs-max = 0.0` on
> 2026-06-11). The "raw lat/lon as unscaled features" explanation below is
> therefore also wrong — no coordinate ever reached the model. After the fix
> (real, min-max-normalized coordinates, commit `421f1e7`), `coordinates`
> **helps** the LSTM by ≈ −33 % vs `none` (1.155 vs 1.723 on 1990), not
> +4 %. The non-coordinate rows stand. Full corrected results:
> [`2026-06-13-swiss3dt-results.md`](2026-06-13-swiss3dt-results.md).
> Original text below is preserved unedited as the as-presented record.

> **One-line takeaway:** On Swiss-River-1990, attaching a near-zero-cost entity
> identifier to a plain LSTM cuts per-station RMSE by **up to 32 %** and lets a
> 55 K-parameter LSTM **outperform a 410 K-parameter PatchTST transformer** —
Expand Down Expand Up @@ -35,7 +50,7 @@ that row.**

| dataset | model (split) | `none` baseline | embedding | onehot | sinusoidal | random | coordinates |
|---|---|---|---|---|---|---|---|
| **swiss-river** | LSTM (per_entity) | 1.725 °C | −21.4 % | 🟢 **−32.1 %** | −30.9 % | −31.0 % | 🔴 +4.1 % |
| **swiss-river** | LSTM (per_entity) | 1.725 °C | −21.4 % | 🟢 **−32.1 %** | −30.9 % | −31.0 % | 🔴 +4.1 % ⚠️[R] |
| **swiss-river** | PatchTST (multi-ch) | 1.382 °C | 🟢 **−4.7 %** | −1.0 % | −1.2 % | −0.6 % | −0.1 % |
| **swiss-river** | DLinear (multi-ch) | 1.287 °C | **−0.7 %** | −0.7 % | −0.4 % | −0.4 % | −0.5 % |
| **traffic** | LSTM (multi-ch) | 0.0280 † | 🟢 **−4.5 %** | −1.7 % | _deferred_ | _deferred_ | N/A |
Expand All @@ -50,8 +65,16 @@ occupancy fraction for traffic; ‡ standardised power for electricity. Absolute
RMSE units differ by dataset — the **Δ%** column is the cross-comparable
signal._ `coordinates` is only meaningful for swiss-river (has lat/lon).

> ⚠️[R] **RETRACTED (2026-06-13):** the `coordinates +4.1 %` cell is a
> zero-vector artifact (see the correction banner at the top). Corrected:
> `coordinates` ≈ −33 % vs `none` on swiss-river-1990.

![results heatmap](figures/entity-id-2026-05-15/results-heatmap-all.png)

> ⚠️[R] The **`coordinates` column** of this heatmap is invalid (zero-vector
> bug, see top banner). Read it with that column greyed out; the corrected
> coordinate bars are in `2026-06-13-swiss3dt-results.md`.

**Cross-dataset signal (read the heatmap):**

1. **`PatchTST × embedding` is the only universally-effective cell** — −4.7 /
Expand All @@ -66,6 +89,9 @@ signal._ `coordinates` is only meaningful for swiss-river (has lat/lon).
identity signal.
4. **`coordinates` regresses LSTM** (+4.1 %) — raw lat/lon as unscaled features
acts as noise. Needs normalisation / a learned geo-encoder.
> ⚠️[R] **RETRACTED (2026-06-13):** wrong — the input was an all-zero
> vector (bug), not "unscaled lat/lon". Fixed → `coordinates` **helps**
> (≈ −33 %). See top banner + `2026-06-13-swiss3dt-results.md`.
5. **`traffic × PatchTST × onehot` essentially neutral (+0.2 %).** Now that the
cell finished, the pattern holds: PatchTST gains only via `embedding`
(native `add_after_patch`); transparent identifiers on PatchTST barely move
Expand Down Expand Up @@ -144,6 +170,10 @@ largely redundant (PatchTST/DLinear ≤5 % RMSE).
1. **`coordinates` hurts LSTM (+4 % RMSE).** Raw lat/lon fed as two unscaled
features — large-magnitude, low-information columns that act as noise. Needs
normalisation / a learned geo-encoder before it can help. *(actionable fix)*
> ⚠️[R] **RETRACTED (2026-06-13):** the diagnosis was wrong — coordinates
> were never loaded (zero vector), not "unscaled". The actionable fix
> (load topology + min-max normalize, `421f1e7`) is done; `coordinates`
> now helps the LSTM (≈ −33 %).
2. **PatchTST & DLinear barely move (≤5 % RMSE).** In `multi_channel` split
every station is already its own channel, so channel identity is *implicit*
in the layout — an explicit identifier is largely redundant (best case is
Expand All @@ -165,7 +195,10 @@ largely redundant (PatchTST/DLinear ≤5 % RMSE).
- **Controlled comparison:** re-run PatchTST/DLinear in `per_entity` to remove
the split-mode confound.
- **Multi-seed** (≥3) for the headline cells → significance bands.
- **Fix `coordinates`:** normalise + try a small geo-MLP encoder.
- ~~**Fix `coordinates`:** normalise + try a small geo-MLP encoder.~~
✅ **Done (2026-06-13):** the regression was a zero-vector bug, not
scaling; fixed by loading topology + min-max normalization (`421f1e7`).
`coordinates` now helps (≈ −33 %).
- Ablation: parameter-matched baseline (does the `EntityWrapper`'s extra linear
layer explain part of the embedding gain?).

Expand Down
96 changes: 96 additions & 0 deletions docs/research/2026-06-13-swiss3dt-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# swiss-river × LSTM entity-identifier results (swiss3dt, 2026-06-13)

Per-entity LSTM on three Swiss river-temperature datasets, six identifier
modes, **single seed 2026**, 50-trial Ray HPO per cell, dropout tuned
`{0, 0.1}`, batch_size 32 (fixed), 30 epochs, per-trial deterministic
seeding, single chronological 80/20 hold-out (no CV).

Numbers are read straight off the run `results.json` files by
`tools/plot_swiss3dt_results.py` (no hand-copied values). Raw table:
`figures/swiss3dt-2026-06-13/swiss3dt-rmse.csv`.

## Results — test RMSE (°C, denormalized)

![RMSE by mode](figures/swiss3dt-2026-06-13/swiss3dt-rmse-by-mode.png)

| dataset (stations) | none | embedding | onehot | random | sinusoidal | coordinates |
|---|---|---|---|---|---|---|
| 1990 (28) | 1.723 | 1.289 | 1.119 | 1.139 | **1.116** | 1.155 |
| 2010 (63) | 1.642 | 1.368 | **1.201** | 1.222 | 1.224 | 1.255 |
| zurich (15) | 1.553 | 1.378 | **1.237** | 1.249 | 1.259 | 1.276 |

Best HPO picks per cell (d_model / e_layers / dropout / identifier-dim) and
the artifact-level verification of the tuned dims live in the task ledger
(`liulian-docs/docs/tasks/liulian-python/2026-06.md`, 2026-06-13).

## What we can and cannot claim (research-critic audit)

**Defensible (single seed):**

- Every identifier mode beats the `none` baseline by a large margin —
embedding ≈ −20 to −25 %, the four transparent modes ≈ −19 to −35 %,
consistently across all three datasets.
- Embedding and the transparent modes are the strongest; the transparent
modes sit a touch lower in RMSE than embedding on every dataset.

**NOT claimed (insufficient evidence):**

- *"Transparent identifiers outperform learned embeddings."* The gap
(e.g. 1.289 vs 1.116 on 1990) is from a **single seed** with no variance
estimate; it may not exceed run-to-run noise.
- *Any ranking among the four transparent modes.* They fall within
~0.04 °C of each other — not separable at one seed.
- *Generalization beyond Swiss river temperature.* All three datasets are
the same domain.

**Known confound:** transparent modes concatenate the identifier block to
`x_enc`, enlarging `enc_in` and hence the LSTM input weights (random
≈ 55 k params vs embedding ≈ 51 k vs none ≈ 51 k). "Identifier type" is
entangled with input capacity. A capacity-matched control is needed before
attributing the gain purely to the identifier.

**To harden** (tracked as task #32): ≥3 seeds with mean ± std and a paired
test; a capacity-matched `none` control (widen `enc_in` with random /
padding channels).

## Coordinates: why it flipped from "hurts" to "helps"

![Coordinates old vs new](figures/swiss3dt-2026-06-13/swiss3dt-coordinates-fix.png)

The 2026-05 matrix reported `coordinates` as **regressing** the LSTM. On
swiss-river-1990 × LSTM the old run (`swissriver-lstm-REAL-20260512`, the
batch the advisor slide used — its onehot 1.1717 matches the slide's 1.171)
gave coord 1.630 vs none 1.561 (**+4.4 %** — worse than no identifier). The
new run gives coord 1.155 vs none 1.723 (**−33 %** — a clear benefit). The
two `none` baselines are at a similar level (1.561 vs 1.723), so the flip is
in the coordinate effect itself, and the cause is a fixed bug:

- **Old = zero-vector bug.** The identifier-matrix default is
`graph_mode='none'`, and the old code only loaded the dataset topology
(which carries the station `(x, y)` from the graph file) when a graph
mode was active. So `coordinates` received an **empty** coordinate map
and `make_entity_features` fell back to `torch.zeros(2)` — every station
got the same zero vector. Measured directly on 2026-06-11: the coordinate
channels had `abs-max = 0.0`. The 2026-05 slide's description of "raw
lat/lon as unscaled features" was wrong — no coordinate ever reached the
model. With two constant-zero channels, `coordinates` was effectively
`none` plus a useless widening, hence ≈ none / slightly worse.
- **New = real, normalized coordinates** (commit `421f1e7`). Three changes:
1. load the topology whenever `identifier_mode='coordinates'` (not only
for graph modes), so the `(x, y)` map is actually populated;
2. min-max normalize coordinates per dimension over the dataset's own
stations (raw CH1903 metres are ~1e5–1e6 and would dwarf the scaled
inputs);
3. raise instead of silently zero-filling when a coordinate is missing
(no-fake-features rule).

So the flip is not a modelling surprise — the old "coordinates hurt"
finding was an artifact of a silent zero-feature bug, and is **retracted**.
The 2026-05 slide/analysis should be annotated accordingly (task #29).

## Reproduce

```bash
# regenerate figures + CSV from the local result artifacts
python tools/plot_swiss3dt_results.py
```
128 changes: 128 additions & 0 deletions docs/research/2026-06-14-swiss-mc-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# swiss-river × {DLinear, PatchTST} entity-identifier results (multi_channel, 2026-06-14)

DLinear and PatchTST on the three Swiss datasets in **multi_channel** split
(each station is a channel — identity is already implicit in the channel
layout, unlike the per_entity LSTM where the identifier is the *only*
identity signal). Single seed 2026, 50-trial HPO per cell, batch_size fixed
32, 30 epochs. Numbers read from real `results.json` by
`tools/plot_swiss_mc_results.py` (no hand-copied values);
CSV: `figures/swiss-mc-2026-06-14/swiss-mc-rmse.csv`.

> These results only became measurable after fixing four multi_channel bugs
> on 2026-06-14 (commits `fdecec1`, `82f0903`): before that, the transparent
> identifier modes were a silent no-op (== none) and the 2010/zurich cells
> were all-NaN. The 2026-05 dlinear/patchtst transparent results are
> **retracted** (they measured the no-op).

## Results — test RMSE (°C, denormalized)

![RMSE by mode](figures/swiss-mc-2026-06-14/swiss-mc-rmse-by-mode.png)

| model | dataset | none | embedding | onehot | random | sinusoidal | coordinates |
|---|---|---|---|---|---|---|---|
| DLinear | 1990 | 1.281 | 1.286 | 1.286 | 1.283 | 1.282 | 1.279 |
| DLinear | 2010 | 1.355 | 1.356 | 1.356 | 1.361 | 1.358 | 1.357 |
| DLinear | zurich | 1.391 | 1.401 | 1.396 | 1.393 | 1.393 | 1.385 |
| PatchTST | 1990 | 1.374 | **1.304** | 2.189 | 2.059 | 2.108 | 1.815 |
| PatchTST | 2010 | 1.488 | **1.387** | 2.505 | 2.345 | 2.340 | 2.036 |
| PatchTST | zurich | 1.480 | **1.388** | 2.738 | 2.631 | 2.690 | 2.377 |

## What we can and cannot claim (research-critic audit)

**Defensible (single seed; val mirrors test, so not overfitting):**

- **DLinear shows no measurable identifier effect** on swiss multi_channel:
all six modes land within ~1% of `none` on every dataset (HPO val is
equally flat, e.g. 1990 val 0.1103–0.1105 across modes). A linear
decomposition model over channels has no head-room to exploit per-channel
identity, and the wrapper's fusion projection adds none.
- **On PatchTST, the injection POINT — not the identifier type — drives the
result**, and the root cause is **instance normalization** (see the
mechanism section below). `embedding` via `add_after_patch` (injected
*after* patching, in d_model token space, **post-norm**) helps (~−5% vs
none). The transparent modes, injected via `concat_to_x` *before* patching
(**pre-norm**), hurt substantially (+30–85%), and hurt in HPO val too
(1990 onehot val 0.231 vs none 0.124).

**NOT claimed (would over-reach):**

- *"Transparent identifiers are harmful."* Too broad — `embedding` is also
an identifier and it helps. What hurts on PatchTST is the **pre-patch
`concat_to_x` fusion**, which perturbs the per-channel series that
PatchTST then slices into patch tokens. `add_after_patch` avoids this.
- *"DLinear is immune to identity."* We observe no effect; we cannot tell
apart "the linear model can't use identity" from "the fusion projection
discards it" without inspecting the projection weights.
- Anything cross-domain — all three datasets are Swiss water temperature.

**Key caveat (read before citing):** the multi_channel transparent results
reflect the behaviour of **`ChannelTransparentWrapper`** (concat + learnable
per-channel fusion projection), *not* the intrinsic value of the identifier
information. This wrapper is structurally different from the per_entity
`EntityTransparentWrapper` (zero-parameter concat). Cross-split comparisons
must account for this.

## Why PatchTST transparent fails — instance-norm cancels constant identifiers (2026-06-15)

PatchTST.forecast starts with per-channel **instance normalization** (from
the Non-stationary Transformer): it subtracts each channel's mean over time
and divides by its std (`patchtst.py:142-145`). All transparent identifiers
are **per-channel CONSTANTS** (onehot / coordinates / sinusoidal / random
produce one fixed vector per station, the same at every time step). The
`concat_to_x` path adds that constant to the channel's series *before*
PatchTST runs — so instance-norm, which subtracts the per-channel time-mean,
**erases it exactly**.

Verified numerically: feeding `w·x + const_offset` through PatchTST's
instance-norm yields a tensor identical (max diff 8e-6) to normalizing the
plain `x` — the identifier contributes **nothing** after norm, leaving only
the fusion projection's distortion of the signal. Hence transparent on
PatchTST is all cost, no signal → the +30–85% regression.

`embedding` survives because `add_after_patch` injects in **d_model patch-
token space, after** patching/normalization (`_inject_entity_after_patch`,
`patchtst.py:127-138`) — the only injection point downstream of the norm.

**Why the transparent modes can't currently use `add_after_patch`:** that
path is hard-gated to `identifier_mode='embedding'` (`patchtst.py:72`) and is
literally `nn.Embedding(N, d_model)` added to the token — it produces a
d_model vector by construction. A transparent fixed vector has dimension
≠ d_model (onehot=N, coords=2, sin/random=D), so adding it post-patch would
require a learnable `Linear(D, d_model)` projection (no longer "zero-param
transparent", and not implemented). So "can't" = a current implementation
limit + a dimension mismatch, **not** a fundamental impossibility — it's the
clean ablation to run next (below).

## Contrast with the per_entity LSTM (2026-06-13)

| split | model | identifier effect |
|---|---|---|
| per_entity | LSTM | every identifier helps (−25 to −35% vs none) — identity is the ONLY identity signal |
| multi_channel | DLinear | none (≈ flat) — identity already in the channel layout |
| multi_channel | PatchTST | `add_after_patch` embedding helps; pre-patch transparent injection hurts |

The dominant variable across these is the **split mode**, not the model:
per_entity makes each sample identity-blind (so the identifier is decisive),
while multi_channel already encodes identity in the channel index (so an
extra identifier is at best redundant, at worst disruptive to the
architecture). This matches the split-mode confound flagged in the 2026-05
report.

## To harden / explain (follow-ups)

- **Ablation isolating injection point from identifier type**: implement a
transparent `add_after_patch` for PatchTST (project the fixed (N,D) feature
table to d_model with one `Linear(D, d_model)`, add to the patch token like
the embedding path), then rerun PatchTST × {onehot, sinusoidal, random,
coordinates}. If they stop hurting (or help), it confirms the cause is the
pre-norm `concat_to_x` injection being cancelled by instance-norm, not the
identifier type. (Tracked as a task.)
- **Inspect the DLinear fusion-projection weights** to tell "can't use" from
"discards".
- **Multi-seed** (≥3) for variance bands (shared with the lstm follow-up #32).

## Reproduce

```bash
python tools/plot_swiss_mc_results.py
```
Loading