# 12. Мережа на нормований зігзаг

Пара: `BTC_USDT`. Клас фіч: `клас210х4бема10х10х130`.

## Ціль

Навчити мережу передбачати **місце події в нозі зігзага** — нормований
зігзаг `-1 … +1` — за чотирма безрозмірними фічами, взятими тринадцятьма
ЕМА. Дві мітки: поріг `0.8%` (перша) і поріг `2.3%` (друга).

Ціль поточного прогону — **незалежний зігзаг 0.8%**: півоти по самій
ціні, нога нормована **квантилем**, ЕМА10 вже на нормованій нозі
(етап 2). Фічі беруться теж **квантилем**, порахованим на train.
Навчання йде під **ваговим MSE** (етап 2): `w = 1 + α·|y|^β`.

Питання, яке закривається: чи витягує мережа з фіч стакану й обʼємів те,
чого не видно кореляціями (дослідження 11 дало стелю `|r| ≈ 0.4`).

## Дані

| Що | Значення |
| -- | -------- |
| Клас фіч | [`клас210х4бема10х10х130`](../../rules/01-data-and-db.md#клас210х4бема10х10х130) |
| Форма події | `(13, 4)` — 13 періодів ЕМА × 4 фічі, усі в `[-1, 1]` |
| Фічі | `balance`, `d_balance`, `vol_balance`, `price_balance` |
| Періоди ЕМА фіч | 10, 20 … 130 подій (0.6 … 7.6 год) |
| Ціль прогону | незалежний зігзаг 0.8%, нога квантиль, ема10 на нозі |
| Ціль етапу 1 | нормований зігзаг 2.3% і 0.8% на ЕМА ціни, `(13, 2)` на подію |
| Розбиття | по часу, 5 вікон walk-forward + одне фінальне |
| Лосс | WMSE `w = 1 + α·\|y\|^β`, α і β — ручки кроку «окіл» |
| Джерело | `data/datasets/BTC_USDT/class210x4bema10x10x130/` |

Скейлінг фіч (**квантиль** по кожній клітинці `13 × 4`) рахується
**тільки на train** і застосовується до val/test.

## План роботи

1. Підбір даних: клас фіч, підготовка цілей, вибір мітки на train.
2. Незалежний зігзаг 0.8% і ЕМА10 на нозі; фічі — квантиль на train.
3. Підбір технології — шість типів мереж на розбитті по часу.
4. Грід з великим кроком для обраної моделі.
5. Підбір тривалості навчання.
6. Грід-пошук в околі кращого + walk-forward обраної конфігурації.
7. Фінальна тестова модель на мітці 0.8: метрики на test, матриця помилок,
   розподіл прогнозів.
8. Видача моделі: увесь датасет і свіжий відрізок поза розбиттям.
9. Функція втрат: шість способів штрафу на одному розбитті, розподіл
   прогнозів кожного.
10. Те саме на мітці 2.3.

Порядок кроків 3–7 — стандарт
[`rules/03-models.md`](../../rules/03-models.md#типи-мереж) для тестових
моделей, пройдений **під розбиттям по часу**.

## Етап 1. Підбір даних для навчання · `підтверджено`, 2026-08-24

### Клас фіч

`клас210х4бема10х10х130` — чотири безрозмірні фічі `клас210х210х4ц`,
кожна взята тринадцятьма ЕМА (періоди 10, 20 … 130 подій).

```
x[подія, період, фіча] = ема_період(фіча)
фічі:    balance, d_balance, vol_balance, price_balance
періоди: 10, 20, 30, … 130
```

### Підготовка цілей

Ціль будується **з ЕМА ціни**, не з ціни події
([`rules/02-markup.md`](../../rules/02-markup.md#цілі-навчання)):

```
ема_p(price) -> півоти зігзага свого порогу (2.3% або 0.8%) -> нормована нога
```

`-1` у нижньому півоті, `+1` у верхньому, між ними пропорційно ціні.
Ряд-кандидат будується на кожному з тих самих 13 періодів і на обох
порогах — `(13, 2)` на подію. Подія поза зігзагом цілі не має (`NaN`)
і в навчанні не бере участі: 12 подій train, 1 val, 6 test.

Порядок кроків обовʼязковий: спершу згладжування, потім півоти, потім
нормалізація — зігзаг по самій ціні дає втричі слабший звʼязок з фічами
(дослідження 11, етапи 3–4).

### Вибір мітки

Кореляція всіх 52 фіч з усіма 26 кандидатами, Пірсоном і Спірменом,
**тільки на train** (вибір мітки — агрегат по датасету).

Максимальна `|r|` по періодах ЕМА цілі:

| Період цілі | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 | 110 | 120 | 130 |
| ----------- | -- | -- | -- | -- | -- | -- | -- | -- | -- | --- | --- | --- | --- |
| 2.3% макс | .206 | .247 | .252 | .247 | .253 | .280 | .281 | .283 | **.294** | .284 | .282 | .246 | .243 |
| 2.3% середня | .082 | .121 | .144 | .143 | .149 | .169 | .175 | **.177** | .167 | .164 | .164 | .143 | .142 |
| 0.8% макс | .130 | .154 | .177 | .188 | .220 | .266 | .285 | **.296** | .282 | .251 | .244 | .245 | .244 |
| 0.8% середня | .046 | .079 | .105 | .119 | .140 | .175 | **.184** | .182 | .169 | .151 | .146 | .145 | .146 |

**Обрані мітки:**

| Поріг | Мітка | Макс `\|r\|` | Найсильніша фіча | Подій з міткою |
| ----- | ----- | ----------- | ---------------- | -------------- |
| 0.8% | нормований зігзаг на ЕМА **80** (4.7 год) | 0.296 | `vol_balance ема130` (Пірсон −0.296, Спірмен −0.280) | 23 985 |
| 2.3% | нормований зігзаг на ЕМА **90** (5.3 год) | 0.294 | `vol_balance ема130` (Пірсон −0.294, Спірмен −0.288) | 23 927 |

Обидві криві мають горб на 60–100 і падають на краях: короткі цілі
(10–30) шумні, довгі (120–130) розмиті. За середньою `|r|` оптимум трохи
лівіший (80 і 70) — поле `by_mean_abs` у
[`results/target_pick.json`](results/target_pick.json).

## Етап 2. Незалежний зігзаг 0.8%, нога квантиль, ЕМА10 · `підтверджено`, 2026-08-26

### Метод

Ціль будується **незалежним зігзагом**: півоти шукаються по самій ціні
події, без попереднього згладжування ціни. ЕМА йде вже на нормованій
нозі:

```
price -> півоти зігзага 0.8% -> нормована нога -> ема10(нога)
```

Зігзаг тут один на весь ряд і від періодів ЕМА фіч не залежить — звідси
назва. Ряд-кандидат один, а не тринадцять, тому підбір мітки етапу 1 на
нього не поширюється: період `10` у назві — це ЕМА **ноги**, а не ціни.

ЕМА причинна (дивиться тільки назад), межі `[-1, 1]` зберігаються — це
опукла комбінація значень ноги. Подія поза зігзагом цілі не має (`NaN`).

Правило — [`rules/02-markup.md` → Незалежний
зігзаг](../../rules/02-markup.md#незалежний-зігзаг), реалізація —
[`rules/scripts/zigzag_targets.py`](../../rules/scripts/zigzag_targets.py)
→ `independent`.

```bash
python3 rules/scripts/build_class210x4bema10x10x130.py --pair BTC_USDT
```

Цілі лягають поруч із фічами: `<part>_targets_ind.npz` у
`data/datasets/BTC_USDT/class210x4bema10x10x130/`.

### Нормалізації ноги

Нога нормується одним із трьох скейлерів, кожен — **по своїй нозі
окремо** ([`rules/02-markup.md` → Нормалізація
ноги](../../rules/02-markup.md#нормалізація-ноги)):

| Ряд | Формула на нозі | Що показує | Межі | σ ряду |
| --- | --------------- | ---------- | ---- | -----: |
| **розподіл** (`minmax`) | `2·(price − low)/(high − low) − 1` | частку пройденого шляху | −0.954 … 1.000 | 0.469 |
| **робаст** (`robust`) | `(price − медіана)/(q75 − q25)` | те саме в одиницях IQR ноги | −6.372 … 6.010 | 0.738 |
| **квантиль** (`quantile`) | `2u − 1`, `u` — місце ціни в CDF ноги | ранг події в нозі | −0.970 … 0.982 | 0.492 |

Числа — по всьому датасету, 40 222 події з ціллю, після `ема10` на нозі
([`results/zigzag_scalers.json`](results/zigzag_scalers.json)).

```bash
python3 research/12-zigzag-net/scripts/zigzag_scalers.py --target 0.8
```

Ряди йдуть у **розділ 1** вкладки: гістограма кожного й вікно, де всі
три намальовані під ціною з зігзагом. Питання в них одне — де подія
стоїть у своїй нозі; міняється тільки спосіб відповіді.

У навчанні стоїть **квантиль** — ранг події в нозі. Він не залежить від
швидкості руху: подія посередині ноги дістає 0 незалежно від того,
провалилась ціна туди за годину чи повзла добу.

### Фічі — квантиль

Скейлер фіч — **квантиль**, порахований на `train` і застосований до
`val` і `test` як є ([`rules/01-data-and-db.md` → Скейлер, порахований на
train](../../rules/01-data-and-db.md#скейлер-порахований-на-train)).
Кожна з 52 клітинок `13 × 4` дістає свій набір опорних квантилів:
значення міняється на своє місце в розподілі train, шкала `[-1, 1]`.

### Лосс — ваговий MSE

Під MSE вихід моделі стискається до середини ноги: для прогнозу за
найменшими квадратами `σ_прогнозу ≈ |r|·σ_цілі`, і при слабкому сигналі
менша за модулем відповідь просто дешевша. Щоб середина перестала бути
вигідною, помилка на краях робиться дорожчою
([`rules/03-models.md` → Лосс](../../rules/03-models.md#лосс-mse-і-ваговий-mse)):

```
loss = Σ w·(p − y)² / Σ w        w = 1 + α·|y|^β
```

- Вага рахується **з цілі**: вона не тягне модель у бік більшої
  відповіді, а тільки міняє ціну помилки. Півот (`|y| → 1`) важить
  `1 + α`, середина — `1`.
- Знаменник — сума ваг: лосс лишається в шкалі MSE, і `α` не міняє
  ефективну швидкість навчання.
- Та сама вага йде в лосс ранньої зупинки.
- `α = 0` дає рівно MSE, і ця точка **щоразу лишається в сітці** кроку 6
  — перевірка, чи потрібна вага взагалі.
- На кроках 3–5 ваги стоять фіксовані (`α = 3, β = 2`): порівнюються
  сімейства й ручки, а не ваги.

### Результат

| Ціль | Півотів | Ніг | Подій з ціллю |
| ---- | ------: | --: | ------------: |
| незалежний зігзаг 0.8%, ема10 на нозі | 685 | **684** | 40 222 з 40 242 |
| зігзаг 0.8% на ема80 ціни (етап 1) | 111 | 110 | 39 990 |

### Висновки

- **Ніг стало 684 замість 110.** Це і є головне: вибірка міряється
  ногами, а не подіями ([`rules/03-models.md` → Розбиття по
  часу](../../rules/03-models.md#розбиття-по-часу)), і похибка частки
  падає з 4.8 п.п. до 1.9 п.п. на тому самому датасеті.
- Ціль лишається гладкою: ЕМА10 знімає пилку нормованої ноги, не
  зсуваючи півоти — згладжується нога, а не ціна.

## Етап 3. Підбір технології · `підтверджено`, 2026-08-26

### Метод

**Розбиття — по часу.** Перемішане розбиття (`rules/scripts/split.py`) для
цієї цілі не годиться: ціль міняється повільно, тому сусідні події однієї
ноги майже однакові, а перемішування розкидає їх по `train`, `val` і
`test`.

```bash
python3 research/12-zigzag-net/scripts/neighbours.py \
    --target 0.8 --kind ind --leg quantile
```

| Вибірка | Медіанний розрив до найближчої події train | Сусід — сама сусідня подія |
| ------- | -----------------------------------------: | -------------------------: |
| val | 210 с | 63.7% |
| test | 210 с | 62.5% |

Підстановка цілі найближчої події train дає на test MAE **0.0521** і знак
**96.04%** — без жодного навчання
([`results/neighbours_ind_quantile_0_8.json`](results/neighbours_ind_quantile_0_8.json)).
На перемішаному розбитті `test` міряє інтерполяцію між сусідами з
`train`, а не прогноз.

Тому розбиття тут — [`rules/scripts/split_time.py`](../../rules/scripts/split_time.py),
правило в [`rules/03-models.md` → Розбиття по часу](../../rules/03-models.md#розбиття-по-часу):
`train | val | test` суцільними відрізками підряд, між ними проміжок
(край ріжеться по півоту, і не ближче ніж за 130 подій — найдовша ЕМА
фіч), і не одне розбиття, а **5 вікон walk-forward**.

```
вікно 1  ├──── train ────┤▒│ val │▒│ test │
вікно 2  ├────── train ──────────┤▒│ val │▒│ test │
...                                          ▒ — проміжок, події викидаються
```

**Що порівнюємо.** Шість типів на однакових даних і бюджеті, параметри за
замовчуванням кожного типу: порівнюються сімейства, а не налаштування.
Кожен тип прогнано на всіх 5 вікнах, метрика — середній MAE на val.
`test` у виборі не бере участі.

```bash
python3 research/12-zigzag-net/scripts/walk_forward.py \
    --target 0.8 --step tech --kind ind --leg quantile --scale quantile
```

### Результат

[`results/ind_quantile_wmse_tech_0_8.json`](results/ind_quantile_wmse_tech_0_8.json):

| Кандидат | Параметрів | lr | α | β | терпіння | MAE на val | знак на val | Пірсон на val | σ до цілі | train−val | с |
| -------- | ---------: | -: | -: | -: | -------: | ---------: | ----------: | ------------: | --------: | --------: | -: |
| linear | 53 | 0.001 | 3 | 2 | 10 | 0.4157 | 57.64% | 0.2472 | 0.390 | 0.0225 | 3.8 |
| mlp | 5505 | 0.001 | 3 | 2 | 10 | 0.4210 | 56.00% | 0.2076 | 0.398 | 0.1289 | 7.2 |
| transformer | 26017 | 0.001 | 3 | 2 | 10 | 0.4256 | 55.28% | 0.1815 | 0.419 | 0.1417 | 39.6 |
| gru | 3681 | 0.001 | 3 | 2 | 10 | 0.4227 | 55.34% | 0.1912 | 0.388 | 0.0904 | 39.6 |
| cnn | 3553 | 0.001 | 3 | 2 | 10 | 0.4255 | 55.16% | 0.1573 | 0.358 | 0.1592 | 9.6 |
| dilated | 6657 | 0.001 | 3 | 2 | 10 | 0.4266 | 54.88% | 0.1611 | 0.371 | 0.1367 | 10.8 |

`σ до цілі` — `σ прогнозу / σ цілі` на val: заради нього ваговий лосс і
береться. Порядок у таблиці — за MAE на val.

`cnn` — дві згортки ядром 3 підряд, поле зору 5 періодів з 13.
`dilated` — три згортки ядром 3 з кроками розрідження 1-2-4, поле зору 15.

### Висновки

- **Виграла лінійна модель** — 53 параметри. Порядок сімейств той самий,
  що й під чистим MSE: лосс не змінив, хто кращий.
- **Вага одразу видно на розкиді**: `σ до цілі` 0.36 … 0.42 проти 0.34 у
  того самого linear під MSE — вихід розтягнувся, як і мало бути.
- **Розрив train−val росте з розміром моделі**: 0.0225 у linear проти
  0.159 у cnn.
- Далі йде linear.

## Етап 4. Грід з великим кроком · `підтверджено`, 2026-08-26

### Метод

Сітка переможця етапу 3. У лінійної моделі одна ручка — швидкість
навчання, тому грід іде по ній.

```bash
python3 research/12-zigzag-net/scripts/walk_forward.py \
    --target 0.8 --step coarse --kind ind --leg quantile --scale quantile
```

### Результат

[`results/ind_quantile_wmse_coarse_0_8.json`](results/ind_quantile_wmse_coarse_0_8.json):

| Кандидат | Параметрів | lr | α | β | терпіння | MAE на val | знак на val | σ до цілі | с |
| -------- | ---------: | -: | -: | -: | -------: | ---------: | ----------: | --------: | -: |
| linear | 53 | 0.0001 | 3 | 2 | 10 | 0.4140 | 57.72% | 0.392 | 8.2 |
| linear | 53 | 0.0003 | 3 | 2 | 10 | 0.4140 | 57.69% | 0.393 | 4.4 |
| linear | 53 | 0.001 | 3 | 2 | 10 | 0.4157 | 57.64% | 0.390 | 3.1 |
| linear | 53 | 0.003 | 3 | 2 | 10 | 0.4162 | 57.99% | 0.415 | 3.6 |

### Висновки

- Кращий — **lr 0.0001**, val MAE 0.4140, на краю сітки, тому окіл етапу 6
  розширюється вниз.
- Під ваговим лоссом оптимальна швидкість навчання зсунулась на два
  порядки вниз (0.0001 проти 0.027 під MSE): вага збільшує внесок
  рідкісних крайніх подій, і великий крок починає їх переслідувати.

## Етап 5. Тривалість навчання · `підтверджено`, 2026-08-26

### Метод

Рання зупинка по val уже стоїть, тому міряється саме вона: скільки епох
чекати після кращої (`patience`). Ліміт епох — 400, до нього жоден прогін
не доходить.

```bash
python3 research/12-zigzag-net/scripts/walk_forward.py \
    --target 0.8 --step epochs --kind ind --leg quantile --scale quantile
```

### Результат

[`results/ind_quantile_wmse_epochs_0_8.json`](results/ind_quantile_wmse_epochs_0_8.json):

| Кандидат | Параметрів | lr | α | β | терпіння | MAE на val | знак на val | σ до цілі | с |
| -------- | ---------: | -: | -: | -: | -------: | ---------: | ----------: | --------: | -: |
| терпіння 30 | 53 | 0.0001 | 3 | 2 | 30 | 0.4137 | 58.01% | 0.397 | 18.8 |
| терпіння 10 | 53 | 0.0001 | 3 | 2 | 10 | 0.4140 | 57.72% | 0.392 | 7.9 |
| терпіння 3 | 53 | 0.0001 | 3 | 2 | 3 | 0.4147 | 57.29% | 0.380 | 4.3 |

### Висновки

- Кращий — **терпіння 30**: MAE 0.4137, і знак, і розкид теж найкращі.
- Малий `lr` вимагає довшого очікування: під ваговим лоссом різниця між
  терпінням 3 і 30 більша, ніж була під MSE.

## Етап 6. Грід в околі кращого · `підтверджено`, 2026-08-26

### Метод

Кожна ручка рухається на щабель у обидва боки, решта стоїть — правило
«один експеримент, одна зміна». Тут ручок три: `lr` × 3 і ÷ 3, `α` × 3
і ÷ 3, `β` ± 1. У сітці **щоразу лишається `α = 0`** — перевірка, чи
потрібна вага взагалі.

**Окіл рухається.** Якщо кращим стає край околу, крок повторюється
навколо нового кращого — доти, доки кращий не опиниться в центрі.

```bash
python3 research/12-zigzag-net/scripts/walk_forward.py --target 0.8 \
    --kind ind --leg quantile --scale quantile --loss wmse --step fine
```

### Результат

Три раунди, окіл зсувався по `lr` вгору й по `α` вниз:

| Раунд | Центр | Кращий | MAE на val |
| ----- | ----- | ------ | ---------: |
| 1 | lr 0.0001, α 3 | α **1** | 0.4135 |
| 2 | lr 0.0001, α 1 | lr **0.0003** | 0.4131 |
| 3 | lr 0.0003, α 1 | центр | 0.4131 |

Раунд 3
([`results/ind_quantile_wmse_fine_0_8.json`](results/ind_quantile_wmse_fine_0_8.json)):

| Кандидат | lr | α | β | MAE на val | знак на val | σ до цілі | с |
| -------- | -: | -: | -: | ---------: | ----------: | --------: | -: |
| linear | 0.0003 | 1 | 2 | 0.4131 | 57.94% | 0.346 | 9.9 |
| linear | 0.0003 | 1 | 3 | 0.4131 | 57.93% | 0.342 | 10.0 |
| linear | 0.0009 | 1 | 2 | 0.4132 | 58.05% | 0.338 | 9.6 |
| linear | 0.0003 | 1 | 1 | 0.4132 | 57.92% | 0.342 | 10.0 |
| linear | 0.0003 | 0.33 | 2 | 0.4133 | 57.88% | 0.319 | 10.5 |
| linear | 0.0001 | 1 | 2 | 0.4135 | 57.92% | 0.344 | 18.6 |
| linear | 0.0003 | 3 | 2 | 0.4136 | 57.90% | 0.391 | 9.4 |
| **linear** | **0.0003** | **0** | **2** | **0.4145** | 58.02% | **0.283** | 8.9 |

`interior: true` — кращий у центрі, окіл закрито. Обрано **lr 0.0003,
α 1, β 2, терпіння 30**.

**Ваговий лосс виграв у чистого MSE на val**: 0.4131 проти 0.4145 у тієї
самої моделі з `α = 0`, і розкид виріс з 0.283 до 0.346 від σ цілі. Це
відповідь на головне питання етапу: під центральним піком був сигнал,
який MSE ховав, а не порожнеча.

### Walk-forward обраної конфігурації

| Вікно | test | Ніг | Подій | MAE модель | MAE константа | Знак модель | Знак константа | Пірсон | σ прогнозу |
| ----- | ---- | --: | ----: | ---------: | ------------: | ----------: | -------------: | -----: | ---------: |
| 1 | 2026-04-28 08:05 … 2026-05-15 04:23 | 47 | 4024 | 0.4265 | 0.4459 | 57.50% | 50.32% | 0.3132 | 0.185 |
| 2 | 2026-05-15 04:27 … 2026-06-22 04:02 | 95 | 4024 | 0.3920 | 0.3969 | 54.15% | 51.54% | 0.1784 | 0.163 |
| 3 | 2026-06-22 04:06 … 2026-07-13 11:08 | 75 | 4023 | 0.4170 | 0.4214 | 53.24% | 49.52% | 0.1804 | 0.186 |
| 4 | 2026-07-13 11:11 … 2026-07-27 19:08 | 37 | 4021 | 0.4475 | 0.4565 | 54.66% | 50.21% | 0.1637 | 0.185 |
| 5 | 2026-07-27 19:13 … 2026-08-16 19:51 | 41 | 4001 | 0.4415 | 0.4456 | 56.06% | 49.74% | 0.1745 | 0.148 |

- Знак: **55.12% ± 1.50**.
- Краще за константу в **5 з 5** вікон, краще за сусіда — теж у 5 з 5.
- Пірсон додатний у всіх пʼяти вікнах і **рівніший**, ніж під MSE
  (0.164 … 0.313 проти 0.164 … 0.345).

### Скільки цьому можна вірити

Незалежних одиниць у всіх вікнах разом — **295 ніг**:

- `se = 0.5 / sqrt(295)` = **2.91 п.п.**;
- різниця з монеткою — **1.76 se**.

Це **менше**, ніж під чистим MSE (2.11 se): по знаку на walk-forward
ваговий лосс програв. Виграв він на `test` і на свіжому відрізку —
етапи 7 і 8.

## Етап 7. Фінальна тестова модель · `підтверджено`, 2026-08-26

### Метод

Одне розбиття по часу, конфігурація — переможець етапу 6.

```bash
python3 research/12-zigzag-net/scripts/walk_forward.py --target 0.8 \
    --kind ind --leg quantile --scale quantile --loss wmse --final
```

| Вибірка | Період UTC | Подій |
| ------- | ---------- | ----: |
| train | 2026-02-14 17:58 … 2026-05-23 20:55 | 25 071 |
| val | 2026-05-26 03:03 … 2026-06-28 08:36 | 4 655 |
| test | 2026-06-29 03:15 … 2026-08-16 19:51 | 10 033 |

У проміжки викинуто 142 і 205 подій. Ніг у test — **112**. Модель:
linear, lr 0.0003, α 1, β 2, терпіння 30, 53 параметри, зупинка на
епосі 54, seed 0, пристрій mps.

### Метрики

| Прогноз | MAE | RMSE | R² | Пірсон | знак | f1 |
| ------- | --: | ---: | -: | -----: | ---: | -: |
| train | 0.3946 | 0.4639 | 0.0894 | 0.3012 | 60.05% | 0.6016 |
| val | 0.4002 | 0.4761 | −0.0181 | 0.1596 | 54.69% | 0.5589 |
| **test** | **0.4381** | 0.5133 | **0.0067** | 0.1873 | **55.05%** | 0.5561 |
| константа train | 0.4461 | 0.5150 | −0.0000 | — | 49.74% | 0.0 |

### Матриця помилок на test

| | прогноз низ | прогноз верх |
| - | ----------: | -----------: |
| **ціль низ** | 2 698 | 2 292 |
| **ціль верх** | 2 218 | 2 825 |

### Розподіл прогнозів на test

σ прогнозу **0.1831** проти σ цілі 0.5150 — 0.36 замість 0.34 під MSE.
Вихід усе ще стиснутий до середини: `α = 1`, обрана по val, — це
поміркована вага, і гістограма лишається піком біля нуля, тільки трохи
ширшим.

**Головне не тут.** Вага дала не амплітуду, а **узагальнення**:

| | MSE | WMSE α1 β2 |
| - | --: | ---------: |
| MAE на test | 0.4518 | **0.4381** |
| проти константи | гірше на 0.0057 | **краще на 0.0080** |
| R² на test | −0.0519 | **+0.0067** |
| Пірсон на test | 0.0932 | **0.1873** |
| знак на test | 52.58% | **55.05%** |
| σ прогнозу / σ цілі | 0.34 | 0.36 |

Ваги й скейлер —
[`models/linear_0_8_ind_quantile_wmse_v1/`](models/linear_0_8_ind_quantile_wmse_v1/).

### Висновки

- **Модель уперше за все дослідження обійшла константу на фінальному
  test**: MAE 0.4381 проти 0.4461, R² додатний (+0.0067). Під MSE на цьому
  ж розбитті було −0.0519.
- **Пірсон на test подвоївся**: 0.1873 проти 0.0932.
- **Амплітуда майже не змінилась** (0.34 → 0.36): по val вигіднішою
  виявилась мала вага. Розтягнути гістограму сильніше можна, зафіксувавши
  більший `α`, але val за це не голосує — сітка кроку 6 щоразу обирала
  `α = 1` проти `α = 3` і `α = 9`.
- **Виграла найпростіша модель із усіх шести** — 53 параметри.

## Етап 8. Видача моделі · `підтверджено`, 2026-08-26

```bash
python3 research/12-zigzag-net/scripts/predict_series.py \
    --final final_ind_quantile_wmse --kind ind --leg quantile
python3 research/12-zigzag-net/scripts/holdout.py --kind ind --leg quantile \
    --final final_ind_quantile_wmse \
    --from '2026-08-14 14:00' --to '2026-08-25 00:00'
```

### Ціна, зігзаг, ціль і видача моделі на всьому датасеті

40 242 події, з ціллю 40 222, ніг зігзага 684
([`results/predict_series_ind_quantile_wmse.json`](results/predict_series_ind_quantile_wmse.json)):

| Ряд | Що | Межі |
| --- | -- | ---- |
| `price` + `pivots` | ціна події і незалежний зігзаг 0.8 | 58 201 … 82 819 USDT |
| `y` | ціль: ранг події в нозі, ема10 | `[-1, 1]` |
| `p` | видача моделі на тих самих фічах | межі не ставились |

MAE по всьому ряду **0.4061** проти 0.4105 під MSE.

### Проміжний тест на свіжому відрізку

Відрізок **2026-08-14 14:00:00 … 2026-08-25 00:00:00 UTC** береться сирими
CSV поза розбиттям датасету й проходить той самий ланцюг класів; модель і
скейлер — збережені
([`results/holdout_ind_quantile_wmse_0_8.json`](results/holdout_ind_quantile_wmse_0_8.json)).

| Лосс | Подій | Поза датасетом | Ніг | MAE | R² | Пірсон | знак |
| ---- | ----: | -------------: | --: | --: | -: | -----: | ---: |
| **WMSE α1 β2** | 2 512 | 2 308 | 44 | **0.3871** | **0.0996** | **0.3442** | **60.71%** |
| MSE | 2 512 | 2 308 | 44 | 0.3954 | 0.0642 | 0.2790 | 59.63% |

### Висновки

- **Ваговий лосс кращий за всіма метриками відрізка**: MAE, R², Пірсон і
  знак — усі чотири в його бік.
- Ніг у вікні 44, `se` частки 7.5 п.п., тому 60.71% — це 1.43 se від
  монетки. Вікно само по собі нічого не доводить, але воно узгоджене і з
  walk-forward (55.12%), і з фінальним test (55.05%).
- Разом з етапом 7: **вага на краях ноги дала узагальнення, а не
  амплітуду.** Модель не почала відповідати сміливіше — вона почала
  відповідати правильніше на даних, яких не бачила. Причина в тому, що
  під MSE майже всю вагу градієнта тягнула середина ноги, де сигналу
  найменше; вага перенесла увагу на краї, де він є.


## Етап 9. Функція втрат · `підтверджено`, 2026-08-26

### Метод

Ваговий MSE згинає межу `σ_прогнозу ≈ |r|·σ_цілі`, але не прибирає:
потроєння `α` додає до `σ_прогнозу / σ_цілі` близько 0.035 (видно в
сітці етапу 6), тому амплітуду вагою далеко не зсунути. Поруч
ставляться **пʼять інших способів** штрафувати помилку
([`rules/03-models.md` → Інші функції
втрат](../../rules/03-models.md#інші-функції-втрат)):

| Спосіб | Формула | Ідея |
| ------ | ------- | ---- |
| **ваговий MSE** (поточний) | `Σ w·(p − y)² / Σ w`, `w = 1 + α·|y|^β` | край дорожчий за середину |
| **розмах** | `WMSE + λ·(σ_цілі − σ_прогнозу)²`, `λ = 1` | σ стоїть у лоссі прямо |
| **пост-калібрування** | `p' = shift + (σ_цілі/σ_прогнозу)·p` | та сама модель, вихід розтягнутий |
| **кореляція** | `1 − Пірсон(p, y)`, далі калібрування | масштаб не штрафується взагалі |
| **пінбол** | `Σ_q max(q·e, (q−1)·e) / 9`, `e = y − p_q` | девʼять виходів: замість точки — розподіл |
| **псевдо-Huber** | `δ²·(√(1 + (e/δ)²) − 1)`, `δ = 0.5` | оптимум їде від середнього до медіани |

Дані, модель, скейлер, seed і бюджет спільні; розбиття — те саме
`train | val | test` етапу 7, тому числа порівнюються з ним напряму.
Міняється рівно одне: чим штрафується помилка. Калібрування рахується
**на train** і застосовується як є.

Усі функції написані так, щоб рахуватись **однаково на torch і на
numpy**: тим самим виразом іде навчання і рання зупинка по val. Тому
замість Huber узятий псевдо-Huber — він гладкий і без гілок.

```bash
python3 research/12-zigzag-net/scripts/losses.py --target 0.8 \
    --kind ind --leg quantile --scale quantile
```

### Результат

`linear`, lr 0.0003, терпіння 30, seed 0; test — 10 033 події, 112 ніг
([`results/losses_ind_quantile_0_8.json`](results/losses_ind_quantile_0_8.json)):

| Штраф | val MAE | test MAE | R² | Пірсон | знак | σ до цілі |
| ----- | ------: | -------: | -: | -----: | ---: | --------: |
| ваговий MSE (поточний) | 0.4002 | 0.4381 | 0.0067 | 0.1873 | 55.05% | 0.356 |
| штраф за розмах | 0.4373 | 0.4634 | −0.1987 | 0.1951 | 55.10% | 0.681 |
| WMSE + пост-калібрування | 0.5154 | 0.5404 | −0.7182 | 0.1873 | 55.13% | 1.055 |
| кореляційний лосс | 0.5168 | 0.5481 | −0.7735 | 0.1682 | 54.33% | 1.063 |
| пінбол по 9 квантилях | **0.3985** | 0.4371 | 0.0193 | 0.1868 | 54.95% | 0.311 |
| псевдо-Huber | 0.3999 | **0.4355** | **0.0287** | **0.2025** | **55.45%** | 0.313 |
| константа train | — | 0.4461 | 0 | — | 49.74% | 0 |

Кожен рядок має на вкладці **свій розподіл прогнозів на test** поверх
розподілу цілі.

### Висновки

- **Розмах і якість тягнуть у різні боки.** Три способи, що ставлять σ
  на місце (розмах, калібрування, кореляція), дають найгірші MAE і
  відʼємний R²: розтягнути вихід — не те саме, що вгадати. Пост-калі‑
  брування це показує в чистому вигляді: Пірсон **той самий** 0.1873,
  бо афінне перетворення не міняє порядок, а MAE гірший на 23%.
- **Розкид не є метрикою.** σ = 1.05 від цілі дістається множенням на
  число з train, без жодного навчання.
- **Псевдо-Huber кращий за ваговий MSE на test** за всіма чотирма
  метриками одразу: MAE 0.4355 проти 0.4381, R² 0.0287 проти 0.0067,
  Пірсон 0.2025 проти 0.1873, знак 55.45% проти 55.05%. Він же зупинився
  на 10-й епосі замість 54-ї.
- **На val три точкові лоси стоять поруч**: 0.3985 (пінбол), 0.3999
  (Huber), 0.4002 (WMSE) — різниця 0.4%, за правилом вибору
  ([`rules/03-models.md`](../../rules/03-models.md#типи-мереж)) вони
  нерозрізненні. Тому заміна лосса прогону — це окреме рішення, а не
  висновок цієї таблиці: перевіряти треба walk-forward, де вікон пʼять.
- Амплітуду жоден точковий лосс не підняв: 0.31–0.36 від σ цілі проти
  0.28 у чистого MSE. Стеля тут не в штрафі, а в `r ≈ 0.25`.


---

## Відтворення

```bash
# дані й цілі: обидва способи побудови, незалежний — у трьох нормалізаціях
python3 rules/scripts/build_class210x4bema10x10x130.py --pair BTC_USDT
python3 scripts/target_pick.py --pair BTC_USDT          # етап 1

# розділ 1 вкладки: розподіл / робаст / квантиль
python3 research/12-zigzag-net/scripts/zigzag_scalers.py --target 0.8

# етап 3 — чому розбиття по часу
python3 research/12-zigzag-net/scripts/neighbours.py \
    --target 0.8 --kind ind --leg quantile

# етапи 3-6 — кроки стандарту під розбиттям по часу
W="python3 research/12-zigzag-net/scripts/walk_forward.py --target 0.8 \
   --kind ind --leg quantile --scale quantile --loss wmse"
$W --step tech
$W --step coarse
$W --step epochs
$W --step fine     # повторювати, доки interior: true

# етап 7 — фінальна тестова модель
$W --final

# етап 8 — видача моделі
python3 research/12-zigzag-net/scripts/predict_series.py \
    --final final_ind_quantile_wmse --kind ind --leg quantile
python3 research/12-zigzag-net/scripts/holdout.py --kind ind --leg quantile \
    --final final_ind_quantile_wmse \
    --from '2026-08-14 14:00' --to '2026-08-25 00:00'

# етап 9 — функція втрат: шість способів на тому самому розбитті
python3 research/12-zigzag-net/scripts/losses.py --target 0.8 \
    --kind ind --leg quantile --scale quantile

# тести
python3 -m unittest discover -s tests -t tests
```

Потрібен готовий `data/datasets/BTC_USDT/class210х210х4c/` (як він
будується — `rules/01-data-and-db.md`). Кожен крок читає результат
попереднього, тому порядок обовʼязковий.

## Файли

| Файл | Що |
| ---- | -- |
| [`rules/scripts/class210x4bema10x10x130.py`](../../rules/scripts/class210x4bema10x10x130.py) | клас фіч |
| [`rules/scripts/zigzag.py`](../../rules/scripts/zigzag.py) | зігзаг і нормалізація ноги трьома скейлерами |
| [`rules/scripts/zigzag_targets.py`](../../rules/scripts/zigzag_targets.py) | цілі: `build` — на ЕМА ціни, `independent` — незалежний зігзаг |
| [`scripts/zigzag_scalers.py`](scripts/zigzag_scalers.py) | три нормалізації ноги для розділу 1 вкладки (етап 2) |
| [`results/zigzag_scalers.json`](results/zigzag_scalers.json) | гістограми трьох рядів і вікно під ціною |
| [`rules/scripts/scalers.py`](../../rules/scripts/scalers.py) | скейлери, зокрема квантиль на train (`quantile_fit` / `quantile_apply`) |
| [`rules/scripts/split_time.py`](../../rules/scripts/split_time.py) | розбиття по часу: проміжок по півотах, walk-forward |
| [`scripts/data.py`](scripts/data.py) | завантаження фіч і мітки, скейлер на train |
| [`scripts/train.py`](scripts/train.py) | шість моделей, навчання, WMSE, змінний лосс, метрики |
| [`scripts/target_pick.py`](../../scripts/target_pick.py) | підбір мітки (етап 1) |
| [`results/target_pick.json`](results/target_pick.json) | підбір мітки |
| [`scripts/neighbours.py`](scripts/neighbours.py) | розрив у часі між test і train і базовий прогноз по сусіду (етап 3) |
| [`results/neighbours_ind_quantile_0_8.json`](results/neighbours_ind_quantile_0_8.json) | розриви й базовий прогноз |
| [`scripts/walk_forward.py`](scripts/walk_forward.py) | кроки стандарту під розбиттям по часу і фінальна модель (етапи 3–7) |
| [`results/ind_quantile_wmse_tech_0_8.json`](results/ind_quantile_wmse_tech_0_8.json) | підбір технології (етап 3) |
| [`results/ind_quantile_wmse_coarse_0_8.json`](results/ind_quantile_wmse_coarse_0_8.json) | грід з великим кроком (етап 4) |
| [`results/ind_quantile_wmse_epochs_0_8.json`](results/ind_quantile_wmse_epochs_0_8.json) | тривалість навчання (етап 5) |
| [`results/ind_quantile_wmse_fine_0_8.json`](results/ind_quantile_wmse_fine_0_8.json) | окіл кращого і walk-forward (етап 6) |
| [`results/final_ind_quantile_wmse_0_8.json`](results/final_ind_quantile_wmse_0_8.json) | фінальна тестова модель (етап 7) |
| [`models/linear_0_8_ind_quantile_wmse_v1/`](models/linear_0_8_ind_quantile_wmse_v1/) | ваги фінальної моделі і квантильний скейлер її train |
| [`scripts/predict_series.py`](scripts/predict_series.py) | видача моделі на всьому датасеті: ціна, зігзаг, ціль, прогноз (етап 8) |
| [`results/predict_series_ind_quantile_wmse.json`](results/predict_series_ind_quantile_wmse.json) | три ряди на спільній осі часу і півоти зігзага |
| [`scripts/holdout.py`](scripts/holdout.py) | проміжний тест: свіжий відрізок через збережену модель (етап 8) |
| [`results/holdout_ind_quantile_wmse_0_8.json`](results/holdout_ind_quantile_wmse_0_8.json) | ряди й метрики відрізка, півоти зігзага 0.8 |
| [`scripts/losses.py`](scripts/losses.py) | шість функцій втрат на одному розбитті (етап 9) |
| [`results/losses_ind_quantile_0_8.json`](results/losses_ind_quantile_0_8.json) | метрики й розподіл прогнозів кожного способу |
| [`results/class_report.json`](results/class_report.json) | звіт побудови класу й цілей |
| [`tests/test_class210x4bema10x10x130.py`](../../tests/test_class210x4bema10x10x130.py) | тести класу й обох цілей |
| [`tests/test_scalers.py`](../../tests/test_scalers.py) | тести скейлерів, зокрема квантиля на train |
| [`tests/test_split_time.py`](../../tests/test_split_time.py) | тести розбиття по часу |
| [`tests/test_zigzag.py`](../../tests/test_zigzag.py) | тести зігзага й нормалізації ноги |
| [`tests/test_zigzag_scalers.py`](../../tests/test_zigzag_scalers.py) | тести розділу 1: три ряди, гістограми, вікно |
| [`tests/test_walk_forward.py`](../../tests/test_walk_forward.py) | тести кроків стандарту, цілі `ind` і квантиля |
| [`tests/test_holdout.py`](../../tests/test_holdout.py) | тести проміжного тесту й сусідів |
| [`tests/test_target_pick.py`](../../tests/test_target_pick.py) | тести підбору мітки |
| [`tests/test_losses.py`](../../tests/test_losses.py) | тести шести функцій втрат і калібрування |
| [`tests/test_dashboard.py`](../../tests/test_dashboard.py) | тести вкладки «Навчання мережі» |

## Вкладка

[Навчання мережі](http://localhost:8080/training) — розділ 1
«Нормалізації ноги зігзага», розділ 2 «Функція втрат» (шість способів
штрафу, у кожного свій розподіл прогнозів на test), далі етапи 1–8 таблицями,
ряди ціни, цілі й видачі моделі на всьому датасеті та на свіжому
відрізку. Дані вкладки: `/api/training`, `/api/predictions`,
`/api/holdout` ([`dashboard/app.py`](../../dashboard/app.py) →
`TRAINING_RUN`).
