File size: 12,010 Bytes
51bb0b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Modular-TS training model: a Timestral-style network trained from scratch with DHF.

The network *is* `timestral.model.TimestralModel` (Apache-2.0 architecture code, random
init), so parameter names match the open inference package and a trained checkpoint
exports to `config.json` + `model.safetensors` that `timestral.TimestralForecaster`
loads unchanged. We add `forward_train`, the Decoupled-Horizon-Forecasting training pass
(our reconstruction; Mistral has not published it):

- batch layout (B groups, V rows per group, T = N * patch steps); rows are target,
  past-only covariate, known-future covariate, or padding;
- Enc = all N patch embeddings (causal per patch, past-only scaling + asinh);
  Dec = N copies of the learned forecast query; per group a min-context s, decoder
  blocks of K patches anchored at s, s+K, ...; Dec token in block b reads Enc[< b] plus
  the causal Dec chain of its own block (timestral.masks.build_two_stream_time_mask);
- variate attention per patch slot over the V rows of a group with the covariate
  firewall (covariates never read targets; known-future rows never read causal rows);
- target of Dec token p = true patch p normalized in the causal frame of patches < p
  (the "advancing refine frame" the inference code decodes in), asinh-squashed;
- loss = pinball over the 21 quantile knots on observed target values of active patches.

NEVER load Timestral weights into this model (Modified-MIT licence, see NOTICE).
"""

from __future__ import annotations

import math
import os
import sys

import torch
import torch.nn.functional as F
from torch import Tensor

_HERE = os.path.dirname(os.path.abspath(__file__))
_TS_SRC = os.path.join(os.path.dirname(_HERE), "timestral", "src")
if os.path.isdir(_TS_SRC) and _TS_SRC not in sys.path:
    sys.path.insert(0, _TS_SRC)

from timestral.config import TimestralConfig  # noqa: E402
from timestral.layers import Attention, _apply_rotary  # noqa: E402
from timestral.masks import build_two_stream_time_mask, compute_enc_key_validity  # noqa: E402
from timestral.model import ROLE_KNOWN_COV, ROLE_PAST_COV, ROLE_TARGET, TimestralModel  # noqa: E402
from timestral.scaler import apply_affine_asinh_masked, causal_patch_loc_scale  # noqa: E402

ROLE_PAD = -1
QUANTILES_21 = (0.01, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5,
                0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)


def make_config(size: str = "2.5M", context_length: int = 4096) -> TimestralConfig:
    presets = {
        # d_model, heads, mlp, layers-groups
        "2.5M": (128, 2, 320, 2),
        "5M": (192, 3, 512, 2),
        "28M": (384, 6, 1024, 3),
        "tiny": (64, 1, 128, 2),  # CPU tests only
    }
    d, h, mlp, k = presets[size]
    return TimestralConfig(
        patch_size=32, d_model=d, num_heads=h, mlp_hidden_dim=mlp,
        block_schedule=("time", "time", "time", "time_variate") * k,
        quantile_knots=QUANTILES_21, block_size=32, context_length=context_length,
        rope_theta=1e4, rope_partial_factor=0.5, norm_eps=1e-6, attn_bias=False, mlp_bias=False,
        time_attn_gate="sdpa_sigmoid", variate_attn_gate="sdpa_sigmoid", minimum_scale=1e-6,
        use_asinh=True, stabilize_with_global=False, covariate_enabled=True,
        split_context_horizon_attn=True, attend_missing_patches=True, quantile_width_factor=3.0,
    )


def count_params(model: torch.nn.Module) -> dict:
    tot = sum(p.numel() for p in model.parameters())
    emb = sum(p.numel() for n, p in model.named_parameters() if n.startswith("representation"))
    head = sum(p.numel() for n, p in model.named_parameters() if n.startswith("head"))
    return {"total": tot, "embed": emb, "trunk": tot - emb - head, "head": head}


def _variate_attention(attn: Attention, x: Tensor, mask: Tensor, B: int, V: int) -> Tensor:
    """Variate attention per (group, slot) over V rows. x: (B*V, S, D); mask: (B*S, 1, V, V)."""
    R, S, D = x.shape
    inp = x.view(B, V, S, D).permute(0, 2, 1, 3).reshape(B * S, V, D)
    q, k, v = attn._qkv(inp)  # (B*S, V, H, hd)
    out = F.scaled_dot_product_attention(q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2), attn_mask=mask)
    out = out.transpose(1, 2).reshape(B * S, V, D)
    if attn._gate is not None:
        out = out * attn._gate(inp).sigmoid()
    out = attn.wO(out)
    return out.view(B, S, V, D).permute(0, 2, 1, 3).reshape(R, S, D)


def _time_attention(attn: Attention, x: Tensor, mask: Tensor, cos: Tensor, sin: Tensor) -> Tensor:
    q, k, v = attn._qkv(x)
    q, k = _apply_rotary(q, k, cos, sin)
    out = F.scaled_dot_product_attention(q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2), attn_mask=mask)
    out = out.transpose(1, 2).reshape(x.shape[0], x.shape[1], -1)
    if attn._gate is not None:
        out = out * attn._gate(x).sigmoid()
    return attn.wO(out)


class ModularTS(TimestralModel):
    """TimestralModel + DHF training pass (identical parameter names)."""

    def __init__(self, config: TimestralConfig) -> None:
        super().__init__(config)
        self.register_buffer("q_levels", torch.tensor(config.quantile_knots, dtype=torch.float32), persistent=False)
        self._init_weights()

    def _init_weights(self) -> None:
        for name, p in self.named_parameters():
            if p.dim() == 2:
                torch.nn.init.normal_(p, std=0.02)
                if name.endswith("wO.weight") or name.endswith("w3.weight"):
                    p.data.mul_(1.0 / math.sqrt(2 * len(self.config.block_schedule)))
            elif name.endswith(".bias"):
                torch.nn.init.zeros_(p)
        torch.nn.init.normal_(self.representation.forecast_query, std=0.02)

    # ------------------------------------------------------------------ masks
    def build_masks(self, observed: Tensor, roles: Tensor, s: Tensor):
        """observed (B,V,T) bool, roles (B,V) long, s (B,) long -> masks/positions/key validity."""
        c = self.config
        B, V, T = observed.shape
        P, N, R = c.patch_size, T // c.patch_size, B * V
        dev = observed.device
        obs_pp = observed.view(R, N, P).any(-1)
        not_pad = (roles != ROLE_PAD).view(R)
        key_valid = compute_enc_key_validity(obs_pp, c.attend_missing_patches) & not_pad[:, None]
        known = (roles == ROLE_KNOWN_COV).view(R)
        target = (roles == ROLE_TARGET).view(R)
        s_rows = s.repeat_interleave(V)
        time_mask = build_two_stream_time_mask(s_rows, N, c.block_size, key_valid, bidirectional_rows=known)
        # variate mask (B*2N, 1, V, V)
        slot_valid = torch.cat([key_valid, not_pad[:, None].expand(R, N)], dim=1).view(B, V, 2 * N)
        keyok = slot_valid.permute(0, 2, 1)[:, :, None, :]  # (B, 2N, 1, Vk)
        tg = target.view(B, V)
        kn = known.view(B, V)
        forbid = ((~tg)[:, :, None] & tg[:, None, :]) | (kn[:, :, None] & (~kn)[:, None, :])  # (B, Vq, Vk)
        vmask = keyok & ~forbid[:, None]
        eye = torch.eye(V, dtype=torch.bool, device=dev)
        vmask = (vmask | eye).reshape(B * 2 * N, 1, V, V)
        # positions anchored at each group's first observed patch
        grp_obs = obs_pp.view(B, V, N).any(1)  # (B, N)
        first = torch.where(grp_obs.any(-1), grp_obs.float().argmax(-1), torch.zeros(B, device=dev)).long()
        p = torch.arange(N, device=dev)
        pos = (p[None] - first[:, None]).repeat_interleave(V, 0)
        positions = torch.cat([pos, pos], dim=1)
        return time_mask, vmask, positions, obs_pp, target, known

    # ---------------------------------------------------------------- forward
    def forward_train(self, values: Tensor, observed: Tensor, roles: Tensor, s: Tensor, return_preds: bool = False,
                      teacher: Tensor | None = None, teacher_obs: Tensor | None = None, gt_row: Tensor | None = None,
                      lam: float = 0.5, teacher_q_idx: tuple = (2, 4, 6, 8, 10, 12, 14, 16, 18)):
        """values (B,V,T) float (anything where unobserved), observed (B,V,T) bool,
        roles (B,V) long in {-1,0,1,2}, s (B,) min-context patches. Returns (loss, stats)."""
        c = self.config
        B, V, T = values.shape
        P, N, R = c.patch_size, T // c.patch_size, B * V
        x = torch.where(observed, values, torch.zeros_like(values)).float().view(R, T)
        obs = observed.view(R, T)

        with torch.no_grad():
            time_mask, vmask, positions, obs_pp, target_rows, known_rows = self.build_masks(observed, roles, s)
            loc, scale = causal_patch_loc_scale(x[:, None], obs[:, None], P, c.minimum_scale, False)
            loc, scale = loc[:, 0], scale[:, 0]
            scaled = apply_affine_asinh_masked(x, loc, scale, obs, use_asinh=c.use_asinh)
            scaled, _, _ = self._known_cov_scaling(x, obs, scaled, loc, scale, known_rows)
            # DHF targets in the frame of patches < p
            loc_pp = loc.view(R, N, P)[..., 0]
            sc_pp = scale.view(R, N, P)[..., 0]
            f_loc = torch.cat([loc_pp[:, :1], loc_pp[:, :-1]], dim=1)
            f_sc = torch.cat([sc_pp[:, :1], sc_pp[:, :-1]], dim=1)
            y = torch.asinh((x.view(R, N, P) - f_loc[..., None]) / f_sc[..., None].clamp_min(1e-6))
            cnt_before = torch.cumsum(obs.view(R, N, P).sum(-1), 1) - obs.view(R, N, P).sum(-1)
            p_idx = torch.arange(N, device=x.device)
            active = p_idx[None] >= s.repeat_interleave(V)[:, None]
            w = (obs.view(R, N, P) & target_rows[:, None, None] & active[..., None] & (cnt_before >= 2)[..., None]).float()
            if gt_row is not None:  # rows whose ground truth may not be used (teacher-only inputs)
                w = w * gt_row.view(R, 1, 1).float()
            y = torch.where(w > 0, y, torch.zeros_like(y))
            if teacher is not None:
                # teacher (R, T, Qt) raw-space quantiles (anything where ~teacher_obs) -> same advancing frame + asinh
                Qt = teacher.shape[-1]
                tv = torch.where(teacher_obs.view(R, T, 1), teacher.view(R, T, Qt).float(), torch.zeros(1, device=x.device))
                yt = torch.asinh((tv.view(R, N, P, Qt) - f_loc[..., None, None]) / f_sc[..., None, None].clamp_min(1e-6))
                wt = (teacher_obs.view(R, N, P) & target_rows[:, None, None] & active[..., None] & (cnt_before >= 2)[..., None]).float()
                yt = torch.where(wt[..., None] > 0, yt, torch.zeros_like(yt))

        x_enc = self.representation(scaled, obs)
        x_dec = self.representation.forecast_tokens(R, N)
        h = torch.cat([x_enc, x_dec.to(x_enc.dtype)], dim=1)
        cos, sin = self.transformer.rope(h, positions)
        for layer in self.transformer.layers:
            for norm, attn in zip(layer.attn_norms, layer.attns):
                if attn.is_time:
                    h = h + _time_attention(attn, norm(h), time_mask, cos, sin)
                else:
                    h = h + _variate_attention(attn, norm(h), vmask, B, V)
            h = h + layer.mlp(layer.mlp_norm(h))
        h = self.transformer.out_norm(h)
        preds = self.head(h[:, N:]).view(R, N, P, -1).float()  # (R,N,P,Q) normalized, monotone

        if return_preds:
            return preds, f_loc, f_sc
        err = y[..., None] - preds
        tau = self.q_levels.view(1, 1, 1, -1)
        pin = torch.maximum(tau * err, (tau - 1) * err).mean(-1)
        denom = w.sum().clamp_min(1.0)
        loss = (pin * w).sum() / denom
        with torch.no_grad():
            mae = ((y - preds[..., c.median_index]).abs() * w).sum() / denom
        stats = {"pinball": loss.detach(), "mae_med": mae, "n_tgt": denom}
        if teacher is not None:
            pq = preds[..., list(teacher_q_idx)]
            tq = self.q_levels[list(teacher_q_idx)].view(1, 1, 1, -1)
            et = yt - pq
            kd = torch.maximum(tq * et, (tq - 1) * et).mean(-1)
            kd_loss = (kd * wt).sum() / wt.sum().clamp_min(1.0)
            loss = (1 - lam) * loss + lam * kd_loss
            stats["kd"] = kd_loss.detach()
        return loss, stats