Download src/opentslm/model/encoder/TransformerCNNEncoder2D.py from daidv1112/LOTUS: direct link, hf CLI and curl.
- Browser
- Download file 22.7 kB
-
https://huggingface.co/daidv1112/LOTUS/resolve/main/src/opentslm/model/encoder/TransformerCNNEncoder2D.py
- Command line
-
hf download hf://daidv1112/LOTUS/src/opentslm/model/encoder/TransformerCNNEncoder2D.py
-
curl -L -o TransformerCNNEncoder2D.py https://huggingface.co/daidv1112/LOTUS/resolve/main/src/opentslm/model/encoder/TransformerCNNEncoder2D.py
22.7 kB
| # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md) | |
| # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project. | |
| # | |
| # SPDX-License-Identifier: MIT | |
| import math | |
| from typing import List, Tuple | |
| import torch | |
| import torch.nn as nn | |
| from opentslm.model_config import TRANSFORMER_INPUT_DIM, ENCODER_OUTPUT_DIM, PATCH_SIZE | |
| from opentslm.model.encoder.TimeSeriesEncoderBase import TimeSeriesEncoderBase | |
| def _get_1d_sincos_pos_embed(embed_dim: int, pos: torch.Tensor, base: float = 10000.0) -> torch.Tensor: | |
| """ | |
| MAE-style 1D sin-cos embedding. | |
| pos: (M,) float tensor of positions | |
| returns: (M, embed_dim) | |
| """ | |
| assert embed_dim % 2 == 0 | |
| device, dtype = pos.device, pos.dtype | |
| omega = torch.arange(embed_dim // 2, device=device, dtype=dtype) | |
| omega = omega / (embed_dim / 2.0) | |
| omega = 1.0 / (base ** omega) # (D/2,) | |
| out = pos.unsqueeze(1) * omega.unsqueeze(0) # (M, D/2) | |
| emb = torch.cat([torch.sin(out), torch.cos(out)], dim=1) # (M, D) | |
| return emb | |
| class TransformerCNNEncoder2D(TimeSeriesEncoderBase): | |
| """ | |
| Causal patch encoder β each patch attends only to itself and all | |
| preceding patches (causal self-attention mask). | |
| Runs ONCE on the full history window and produces a fixed memory M | |
| that the causal forecast decoder cross-attends to at every generation | |
| step. Causal attention ensures patch representation i encodes only | |
| patches 0..i, preventing the LLM from reading future patch information | |
| out of earlier positions and making the next-patch prediction task | |
| non-trivial. | |
| - 24 causal self-attention layers (TransformerEncoderLayer, causal mask) | |
| - d_model = 768, ff_dim = 3072 | |
| - 12 attention heads | |
| Input: | |
| x: (C, L) β C channels (total across batch), L timesteps | |
| (padded to a multiple of patch_size) | |
| Output: | |
| (C, T, D) β T = L / patch_size patch representations | |
| """ | |
| def __init__( | |
| self, | |
| args=None, | |
| output_dim: int = ENCODER_OUTPUT_DIM, | |
| dropout: float = 0.1, | |
| transformer_input_dim: int = 768, | |
| num_heads: int = 12, | |
| num_layers: int = 24, | |
| patch_size: int = PATCH_SIZE, | |
| ff_dim: int = 3072, | |
| pe_base: float = 10000.0, # legacy single-base fallback | |
| pe_base_t: float | None = None, # overrides pe_base for TIME axis | |
| pe_base_c: float | None = None, # overrides pe_base for CHANNEL axis | |
| return_4d: bool = False, | |
| ): | |
| super().__init__(output_dim, dropout) | |
| self.patch_size = patch_size | |
| # Separate bases for time vs channel. CLI args (`args.pe_base_t/c`) take | |
| # precedence; constructor kwargs are next; legacy `pe_base` is final | |
| # fallback. With separate bases, channel PE can use a small base (e.g. | |
| # 10) so cβ{0..C-1} produces visibly distinct sin-cos signatures, while | |
| # time PE keeps the standard 10000 (good for positions up to ~10k). | |
| _pe_t_arg = getattr(args, "pe_base_t", None) if args is not None else None | |
| _pe_c_arg = getattr(args, "pe_base_c", None) if args is not None else None | |
| self.pe_base_t = float(_pe_t_arg if _pe_t_arg is not None else (pe_base_t if pe_base_t is not None else pe_base)) | |
| self.pe_base_c = float(_pe_c_arg if _pe_c_arg is not None else (pe_base_c if pe_base_c is not None else pe_base)) | |
| self.pe_base = float(pe_base) # legacy attr retained | |
| self.return_4d = return_4d | |
| self.transformer_input_dim = transformer_input_dim | |
| # ββ Patch embedding: (B, 1, L) β (B, D, T) ββββββββββββββββββββββββββ | |
| self.patch_embed = nn.Conv1d( | |
| in_channels=1, | |
| out_channels=transformer_input_dim, | |
| kernel_size=patch_size, | |
| stride=patch_size, | |
| bias=False, | |
| ) | |
| # ββ Frequency patch embedding (optional) βββββββββββββββββββββββββββββ | |
| # rfft on a patch of length P gives P//2+1 complex values β 2*(P//2+1) reals | |
| self._use_freq_embed = getattr(args, "use_freq_embed", False) | |
| # See MLPEncoder2D for why "linear" is an exact linear reparameterisation | |
| # of patch_embed and therefore expressively inert; "spectral" is not. | |
| self._freq_mode = str(getattr(args, "freq_mode", "linear")).lower() | |
| if self._freq_mode not in ("linear", "spectral"): | |
| raise ValueError( | |
| f"freq_mode must be 'linear' or 'spectral', got {self._freq_mode!r}" | |
| ) | |
| if self._use_freq_embed: | |
| freq_input_dim = (patch_size // 2 + 1) * 2 | |
| self.freq_embed = nn.Linear(freq_input_dim, transformer_input_dim, bias=False) | |
| self.concat_proj = nn.Linear(2 * transformer_input_dim, transformer_input_dim, bias=False) | |
| self.input_norm = nn.LayerNorm(transformer_input_dim) | |
| self.input_dropout = nn.Dropout(self.dropout) | |
| # ββ Transformer encoder (24 layers) ββββββββββββββββββββββββββββββββββ | |
| encoder_layer = nn.TransformerEncoderLayer( | |
| d_model=transformer_input_dim, | |
| nhead=num_heads, | |
| dim_feedforward=ff_dim, | |
| dropout=self.dropout, | |
| batch_first=True, | |
| activation="gelu", | |
| norm_first=True, # Pre-LN for stable deep training | |
| ) | |
| self.encoder = nn.TransformerEncoder( | |
| encoder_layer, | |
| num_layers=num_layers, | |
| norm=nn.LayerNorm(transformer_input_dim), # final norm | |
| ) | |
| if transformer_input_dim != output_dim: | |
| self.output_proj = nn.Linear(transformer_input_dim, output_dim) | |
| else: | |
| self.output_proj = nn.Identity() | |
| self.args = args | |
| self._print_param_count() | |
| def _print_param_count(self): | |
| total = sum(p.numel() for p in self.parameters()) | |
| trainable = sum(p.numel() for p in self.parameters() if p.requires_grad) | |
| print(f"π’ Encoder: {total:,} params ({trainable:,} trainable)") | |
| # ------------------------------------------------------------------ | |
| # Incremental (KV-cache) helpers | |
| # ------------------------------------------------------------------ | |
| def _compute_tok( | |
| self, | |
| x: torch.Tensor, # [C, T_new * patch_size] | |
| chan_id: torch.Tensor, # [C] | |
| t_offset: int = 0, # time-position index of the first new patch | |
| ) -> torch.Tensor: | |
| """Patch-embed x and add 2D sin-cos positional encoding. | |
| Returns [C, T_new, transformer_input_dim] after input_norm + dropout. | |
| Mirrors the embedding block in forward() but supports an arbitrary | |
| starting time position (t_offset) so that new patches receive the | |
| correct absolute time PE. | |
| """ | |
| C = x.size(0) | |
| T_new = x.size(1) // self.patch_size | |
| D = self.transformer_input_dim | |
| tok = self.patch_embed(x.reshape(C, 1, -1)).transpose(1, 2) # [C, T_new, D] | |
| if self._use_freq_embed: | |
| x_patches = x.reshape(C, T_new, self.patch_size) | |
| tok = self._fuse_freq(tok, x_patches) | |
| t_pos = torch.arange(t_offset, t_offset + T_new, device=tok.device, dtype=torch.float32) | |
| c_pos = chan_id.to(device=tok.device, dtype=torch.float32) | |
| if getattr(self.args, "use_only_time_pe", False): | |
| pe = _get_1d_sincos_pos_embed(D, t_pos, base=self.pe_base_t).to(tok.dtype) | |
| pe = pe.unsqueeze(0).expand(C, -1, -1) | |
| elif getattr(self.args, "use_only_channel_pe", False): | |
| pe = _get_1d_sincos_pos_embed(D, c_pos, base=self.pe_base_c).to(tok.dtype) | |
| pe = pe.unsqueeze(1).expand(-1, T_new, -1) | |
| else: | |
| if D % 4 == 0: | |
| pe_t = _get_1d_sincos_pos_embed(D // 2, t_pos, base=self.pe_base_t).to(tok.dtype) | |
| pe_c = _get_1d_sincos_pos_embed(D // 2, c_pos, base=self.pe_base_c).to(tok.dtype) | |
| pe = torch.cat( | |
| [pe_t.unsqueeze(0).expand(C, -1, -1), | |
| pe_c.unsqueeze(1).expand(-1, T_new, -1)], | |
| dim=-1, | |
| ) | |
| else: | |
| pe_t = _get_1d_sincos_pos_embed(D, t_pos, base=self.pe_base_t).to(tok.dtype) | |
| pe_c = _get_1d_sincos_pos_embed(D, c_pos, base=self.pe_base_c).to(tok.dtype) | |
| pe = pe_t.unsqueeze(0).expand(C, -1, -1) + pe_c.unsqueeze(1).expand(-1, T_new, -1) | |
| tok = tok + pe | |
| tok = self.input_norm(tok) | |
| tok = self.input_dropout(tok) | |
| return tok # [C, T_new, D] | |
| def tokenize_patches( | |
| self, | |
| x: torch.Tensor, | |
| chan_id: torch.Tensor, | |
| t_offset: int = 0, | |
| ) -> torch.Tensor: | |
| """ | |
| Return patch tokens before the encoder stack. | |
| This exposes the encoder's patch embedding and positional encoding | |
| without running the contextual encoder stack. | |
| """ | |
| if x.size(1) % self.patch_size != 0: | |
| raise ValueError(f"L={x.size(1)} not divisible by patch_size={self.patch_size}") | |
| return self._compute_tok(x, chan_id, t_offset=t_offset) | |
| def build_kv_cache( | |
| self, | |
| x: torch.Tensor, # [C, L] L divisible by patch_size | |
| chan_id: torch.Tensor, # [C] | |
| ) -> Tuple[torch.Tensor, List[torch.Tensor]]: | |
| """Full encoder forward that also returns per-layer input caches. | |
| The cache stores the un-normed input to each transformer layer for | |
| every history patch. forward_incremental() uses these as K/V so | |
| that a new patch can attend to all history without re-encoding it. | |
| Returns: | |
| output : [C, T, output_dim] (same as forward()) | |
| kv_caches : list of num_layers tensors, each [C, T, transformer_dim] | |
| """ | |
| if x.size(1) % self.patch_size != 0: | |
| raise ValueError(f"L={x.size(1)} not divisible by patch_size={self.patch_size}") | |
| tok = self._compute_tok(x, chan_id, t_offset=0) # [C, T, D] | |
| T = tok.size(1) | |
| causal_mask = nn.Transformer.generate_square_subsequent_mask( | |
| T, device=tok.device, dtype=tok.dtype | |
| ) | |
| kv_caches: List[torch.Tensor] = [] | |
| h = tok | |
| for layer in self.encoder.layers: | |
| kv_caches.append(h) # store pre-layer activation | |
| h = layer(h, src_mask=causal_mask, is_causal=True) | |
| if self.encoder.norm is not None: | |
| h = self.encoder.norm(h) | |
| output = self.output_proj(h) | |
| return output, kv_caches | |
| def forward_incremental( | |
| self, | |
| x_new: torch.Tensor, # [C, patch_size] one new patch per channel | |
| chan_id: torch.Tensor, # [C] | |
| t_offset: int, # absolute time index of the new patch | |
| kv_caches: List[torch.Tensor], # per-layer [C, T_past, D]; updated in-place | |
| ) -> torch.Tensor: | |
| """Encode one new patch using cached K/V activations from all past patches. | |
| Because the encoder is causal, position t can only attend to 0..t, so | |
| past positions' outputs never change. Only the new position's output | |
| must be computed, attending to the cached history via the saved | |
| per-layer pre-norm activations. | |
| kv_caches is updated in-place: each entry grows by one column so | |
| successive calls build up the growing sequence correctly. | |
| Returns: [C, 1, output_dim] | |
| """ | |
| # 1. Embed the new patch with correct absolute time PE | |
| h_new = self._compute_tok(x_new, chan_id, t_offset=t_offset) # [C, 1, D] | |
| # 2. Propagate through each transformer layer with cached K/V | |
| for l, layer in enumerate(self.encoder.layers): | |
| h_past = kv_caches[l] # [C, T_past, D] β pre-layer inputs | |
| # norm_first=True: K/V are the normed layer inputs | |
| kv_past = layer.norm1(h_past) # [C, T_past, D] | |
| q_kv_new = layer.norm1(h_new) # [C, 1, D] | |
| # K and V: all past + new token (new token is the last so no causal | |
| # masking needed β it can attend to every preceding position) | |
| k = torch.cat([kv_past, q_kv_new], dim=1) # [C, T_past+1, D] | |
| v = k | |
| attn_out = layer.self_attn(q_kv_new, k, v, need_weights=False)[0] # [C, 1, D] | |
| # Store the pre-layer input for the new position before overwriting h_new | |
| kv_caches[l] = torch.cat([h_past, h_new], dim=1) # grow cache by 1 | |
| # Apply residual + feed-forward (norm_first path) | |
| h_new = h_new + layer.dropout1(attn_out) | |
| h_new = h_new + layer._ff_block(layer.norm2(h_new)) | |
| # 3. Final norm + projection | |
| if self.encoder.norm is not None: | |
| h_new = self.encoder.norm(h_new) | |
| return self.output_proj(h_new) # [C, 1, output_dim] | |
| def _fuse_freq(self, tok, x_patches): | |
| """Fuse a per-patch spectral embedding into the time-domain patch tokens. | |
| See the `_freq_mode` note in MLPEncoder2D.__init__: "linear" reproduces the | |
| legacy behaviour and is expressively inert; "spectral" is genuinely | |
| nonlinear. Shapes are identical, so keep the mode consistent with the | |
| checkpoint being loaded. | |
| """ | |
| import torch as _torch | |
| x_fft = _torch.fft.rfft(x_patches, dim=-1) | |
| if self._freq_mode == "spectral": | |
| feats = _torch.cat([x_fft.abs(), x_fft.angle()], dim=-1) | |
| freq_tok = _torch.nn.functional.gelu(self.freq_embed(feats.to(tok.dtype))) | |
| else: | |
| feats = _torch.cat([x_fft.real, x_fft.imag], dim=-1) | |
| freq_tok = self.freq_embed(feats.to(tok.dtype)) | |
| return self.concat_proj(_torch.cat([tok, freq_tok], dim=-1)) | |
| def forward(self, x: torch.Tensor, chan_id: torch.Tensor, attention_mask: torch.Tensor = None) -> torch.Tensor: | |
| """ | |
| NOTE: chan_id is needed to reconstruct the 2D positional encodings. During processing, the time series are batched into shape (C,L), where C is the TOTAL number of channels across the batched examples | |
| For example, if the batch contains 2 examples, each with 3 channels, then C = 6. chan_id is then [0, 1, 2, 0, 1, 2], which results in similar channel positional encoding for similar channel. L is the length | |
| (padded to the same length across all series in the batch). Now, we will use chan_id to identify which of the 6 channels correspond to the same original channel across the 2 examples, so that we can assign | |
| them the same channel positional encoding. | |
| """ | |
| C, L = x.shape | |
| D = self.transformer_input_dim | |
| # chan_id is 0-indexed and resets to 0 at the start of every sample β | |
| # e.g. [0,1,2, 0,1,2] for two 3-channel samples, or [0,1,2, 0..9] for a | |
| # 3-channel + 10-channel mixed batch. Recover per-sample channel counts | |
| # by finding the zero positions; this also tells us whether the batch | |
| # is uniform-C (fast path) or variable-C (per-sample fallback). | |
| if chan_id.numel() == 0: | |
| sample_starts = [0] | |
| sample_lengths = [C] | |
| else: | |
| sample_starts = (chan_id == 0).nonzero(as_tuple=True)[0].tolist() | |
| if not sample_starts or sample_starts[0] != 0: | |
| # Defensive: if for some reason chan_id doesn't start at 0, | |
| # treat the whole batch as one sample. | |
| sample_starts = [0] | |
| sample_lengths = [C] | |
| else: | |
| sample_lengths = [ | |
| (sample_starts[i + 1] if i + 1 < len(sample_starts) else C) - sample_starts[i] | |
| for i in range(len(sample_starts)) | |
| ] | |
| is_uniform_c = len(set(sample_lengths)) == 1 | |
| n_ch = sample_lengths[0] # only meaningful when uniform | |
| B = len(sample_lengths) # true batch size, even when variable C | |
| if L % self.patch_size != 0: | |
| raise ValueError(f"L={L} not divisible by patch_size={self.patch_size}") | |
| T = L // self.patch_size | |
| x_bc = x.reshape(C, 1, L) | |
| tok = self.patch_embed(x_bc) # (C, D, T) | |
| tok = tok.transpose(1, 2) # (C, T, D) | |
| # ββ Frequency embedding branch ββββββββββββββββββββββββββββββββββββββββ | |
| if self._use_freq_embed: | |
| # reshape into patches, apply rfft, stack real+imag | |
| x_patches = x.reshape(C, T, self.patch_size) # (C, T, P) | |
| tok = self._fuse_freq(tok, x_patches) | |
| t_pos = torch.arange(T, device=tok.device, dtype=torch.float32) | |
| c_pos = chan_id.to(device=tok.device, dtype=torch.float32) | |
| if getattr(self.args, "use_only_time_pe", False): | |
| pe_t = _get_1d_sincos_pos_embed(D, t_pos, base=self.pe_base_t).to(tok.dtype) | |
| pe = pe_t.unsqueeze(0).expand(C, -1, -1) | |
| elif getattr(self.args, "use_only_channel_pe", False): | |
| pe_c = _get_1d_sincos_pos_embed(D, c_pos, base=self.pe_base_c).to(tok.dtype) | |
| pe = pe_c.unsqueeze(1).expand(-1, T, -1) | |
| else: | |
| if D % 4 == 0: | |
| pe_t = _get_1d_sincos_pos_embed(D // 2, t_pos, base=self.pe_base_t).to(tok.dtype) | |
| pe_c = _get_1d_sincos_pos_embed(D // 2, c_pos, base=self.pe_base_c).to(tok.dtype) | |
| pe = torch.cat( | |
| [ | |
| pe_t.unsqueeze(0).expand(C, -1, -1), | |
| pe_c.unsqueeze(1).expand(-1, T, -1), | |
| ], | |
| dim=-1, | |
| ) | |
| else: | |
| pe_t = _get_1d_sincos_pos_embed(D, t_pos, base=self.pe_base_t).to(tok.dtype) | |
| pe_c = _get_1d_sincos_pos_embed(D, c_pos, base=self.pe_base_c).to(tok.dtype) | |
| pe = pe_t.unsqueeze(0) + pe_c.unsqueeze(1) | |
| tok = tok + pe | |
| tok = self.input_norm(tok) | |
| tok = self.input_dropout(tok) | |
| # ββ Cross-channel attention ββββββββββββββββββββββββββββββββββββββββββ | |
| # Fast path: every sample in the batch has the same channel count, so | |
| # we can batch all B samples together as (B, T*n_ch, D). | |
| # Fallback: variable-C batches (e.g. synthetic MV with Cβ[3..10] mixed) | |
| # require per-sample iteration β the reshape (B, n_ch, T, D) is | |
| # invalid when n_ch isn't constant across the batch. | |
| if is_uniform_c: | |
| # (C, T, D) = (B*n_ch, T, D) β (B, n_ch, T, D) β (B, T, n_ch, D) β (B, T*n_ch, D) | |
| tok = tok.reshape(B, n_ch, T, D) | |
| tok = tok.permute(0, 2, 1, 3).reshape(B, T * n_ch, D) | |
| seq_len = T * n_ch | |
| t_idx = torch.arange(seq_len, device=tok.device) | |
| can_attend = (t_idx // n_ch).unsqueeze(1) >= (t_idx // n_ch).unsqueeze(0) | |
| causal_mask = torch.zeros(seq_len, seq_len, dtype=tok.dtype, device=tok.device) | |
| causal_mask.masked_fill_(~can_attend, float('-inf')) | |
| src_key_padding_mask = None | |
| if attention_mask is not None: | |
| bool_pad = ~attention_mask.bool() # (C, T) = (B*n_ch, T) | |
| bool_pad = bool_pad.reshape(B, n_ch, T).permute(0, 2, 1) # (B, T, n_ch) | |
| bool_pad = bool_pad.reshape(B, T * n_ch) | |
| src_key_padding_mask = torch.zeros( | |
| bool_pad.shape, dtype=tok.dtype, device=tok.device | |
| ).masked_fill_(bool_pad, float('-inf')) | |
| tok = self.encoder( | |
| tok, | |
| mask=causal_mask, | |
| src_key_padding_mask=src_key_padding_mask, | |
| is_causal=False, | |
| ) | |
| # (B, T*n_ch, D) β (B, T, n_ch, D) β (B, n_ch, T, D) β (B*n_ch, T, D) | |
| tok = tok.reshape(B, T, n_ch, D) | |
| tok = tok.permute(0, 2, 1, 3).reshape(C, T, D) | |
| else: | |
| # Variable-C fallback: process each sample's MV block separately. | |
| # Each sample has its own (1, T*n_ch_j, D) sequence and its own | |
| # causal mask. Slower than the batched path (B kernel launches | |
| # instead of 1) but correct for arbitrary per-sample channel counts. | |
| out_parts = [] | |
| for start, n_ch_j in zip(sample_starts, sample_lengths): | |
| sample_tok = tok[start:start + n_ch_j].unsqueeze(0) # (1, n_ch_j, T, D) | |
| sample_tok = sample_tok.permute(0, 2, 1, 3).reshape(1, T * n_ch_j, D) | |
| seq_len_j = T * n_ch_j | |
| t_idx_j = torch.arange(seq_len_j, device=tok.device) | |
| can_attend_j = (t_idx_j // n_ch_j).unsqueeze(1) >= (t_idx_j // n_ch_j).unsqueeze(0) | |
| causal_mask_j = torch.zeros( | |
| seq_len_j, seq_len_j, dtype=tok.dtype, device=tok.device | |
| ).masked_fill_(~can_attend_j, float('-inf')) | |
| src_key_padding_mask_j = None | |
| if attention_mask is not None: | |
| sample_mask = attention_mask[start:start + n_ch_j] # (n_ch_j, T) | |
| bool_pad_j = ~sample_mask.bool() | |
| bool_pad_j = bool_pad_j.reshape(1, n_ch_j, T).permute(0, 2, 1) | |
| bool_pad_j = bool_pad_j.reshape(1, T * n_ch_j) | |
| src_key_padding_mask_j = torch.zeros( | |
| bool_pad_j.shape, dtype=tok.dtype, device=tok.device | |
| ).masked_fill_(bool_pad_j, float('-inf')) | |
| sample_tok = self.encoder( | |
| sample_tok, | |
| mask=causal_mask_j, | |
| src_key_padding_mask=src_key_padding_mask_j, | |
| is_causal=False, | |
| ) | |
| # (1, T*n_ch_j, D) β (1, T, n_ch_j, D) β (1, n_ch_j, T, D) β (n_ch_j, T, D) | |
| sample_tok = sample_tok.reshape(1, T, n_ch_j, D) | |
| sample_tok = sample_tok.permute(0, 2, 1, 3).reshape(n_ch_j, T, D) | |
| out_parts.append(sample_tok) | |
| tok = torch.cat(out_parts, dim=0) # (C, T, D) | |
| # ββ Output projection ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| tok = self.output_proj(tok) # (C, T, output_dim) | |
| return tok | |