Skip to content

Commit 1917ef9

Browse files
average pool
1 parent 6fde98a commit 1917ef9

3 files changed

Lines changed: 190 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
_target_: cybench.models.torch.model_components.temporal_encoder.AvgPoolTokenizer
3+
4+
in_dim: ${model.torch_model.temporal_in_dim}
5+
embed_dim: ${model.torch_model.embed_dim}
6+
7+
# Non-overlapping per-channel mean pool (dekadal when patch_size=10).
8+
patch_size: 10
9+
eos_anchor: true
10+
11+
_search_:
12+
patch_size:
13+
type: int
14+
low: 5
15+
high: 15
16+
step: 5

cybench/models/torch/model_components/temporal_encoder.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22

33
import torch
44
import torch.nn as nn
5-
from typing import Optional
5+
import torch.nn.functional as F
66

77
"""
88
Temporal Encoder Module
99
=======================
1010
1111
This module implements a flexible temporal-encoding pipeline composed of:
1212
1. **Tokenizer**: Downsamples raw temporal features and maps them into a fixed
13-
embedding dimension (`embed_dim`). Implemented via a high–receptive-field
14-
Conv1d block (`ConvTokenizer`).
13+
embedding dimension (`embed_dim`). Options include a learnable Conv1d block
14+
(`ConvTokenizer`) or a fixed per-channel mean pool (`AvgPoolTokenizer`).
1515
2. **Processor**: Applies temporal feature transformation without changing the
1616
embedding dimensionality. Two processor families are provided:
1717
- `CNNProcessor`: Lightweight temporal convolutions with preserved shape.
@@ -143,6 +143,65 @@ def forward(self,
143143
return z
144144

145145

146+
class _FixedChannelProjection(nn.Module):
147+
"""Pad or truncate channels without learnable weights."""
148+
149+
def __init__(self, in_dim: int, embed_dim: int):
150+
super().__init__()
151+
self.in_dim = in_dim
152+
self.embed_dim = embed_dim
153+
154+
def forward(self, x: torch.Tensor) -> torch.Tensor:
155+
if self.in_dim == self.embed_dim:
156+
return x
157+
if self.in_dim < self.embed_dim:
158+
return F.pad(x, (0, self.embed_dim - self.in_dim))
159+
return x[..., : self.embed_dim]
160+
161+
162+
class AvgPoolTokenizer(nn.Module):
163+
"""
164+
Fixed temporal tokenizer: non-overlapping per-channel mean over patch_size days.
165+
166+
Mimics dekadal (or N-day) mean aggregation on the model side. No learnable
167+
weights. When ``eos_anchor`` is True (default), the earliest days are dropped
168+
so the last pool window ends at the final timestep (EOS-aligned windows).
169+
"""
170+
171+
def __init__(
172+
self,
173+
in_dim: int,
174+
embed_dim: int,
175+
patch_size: int = 10,
176+
eos_anchor: bool = True,
177+
):
178+
super().__init__()
179+
if patch_size < 1:
180+
raise ValueError(f"patch_size must be >= 1, got {patch_size}")
181+
self.patch_size = patch_size
182+
self.eos_anchor = eos_anchor
183+
self.pool = nn.AvgPool1d(kernel_size=patch_size, stride=patch_size)
184+
self.proj = _FixedChannelProjection(in_dim, embed_dim)
185+
186+
def _trim_to_eos_anchor(self, x: torch.Tensor) -> torch.Tensor:
187+
remainder = x.shape[1] % self.patch_size
188+
if remainder == 0:
189+
return x
190+
return x[:, remainder:, :]
191+
192+
def forward(self, x: torch.Tensor, doys: torch.Tensor) -> torch.Tensor:
193+
del doys
194+
if self.eos_anchor:
195+
x = self._trim_to_eos_anchor(x)
196+
if x.shape[1] < self.patch_size:
197+
raise ValueError(
198+
f"Sequence length {x.shape[1]} is shorter than patch_size "
199+
f"{self.patch_size} after EOS anchoring."
200+
)
201+
z = self.pool(x.transpose(1, 2)).transpose(1, 2)
202+
return self.proj(z)
203+
204+
146205
# ----------------------------------------------------------------------
147206
# 2. Processors
148207
# ----------------------------------------------------------------------
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import pytest
2+
import torch
3+
from hydra import compose, initialize
4+
from hydra.utils import instantiate
5+
6+
from cybench.datasets.data_factory import DataFactory
7+
from cybench.evaluation.eval import evaluate_predictions
8+
from cybench.models.torch.model_components.temporal_encoder import (
9+
AvgPoolTokenizer,
10+
LSTMProcessor,
11+
LastStepPooling,
12+
TemporalEncoder,
13+
)
14+
from cybench.util.config_utils import adjust_model_cfg_to_dataset, remove_keys
15+
16+
17+
def test_avg_pool_tokenizer_shape():
18+
batch_size, seq_len, in_dim, embed_dim, patch_size = 2, 25, 8, 8, 10
19+
tokenizer = AvgPoolTokenizer(
20+
in_dim=in_dim, embed_dim=embed_dim, patch_size=patch_size
21+
)
22+
x = torch.randn(batch_size, seq_len, in_dim)
23+
doys = torch.randint(1, 366, (batch_size, seq_len))
24+
25+
out = tokenizer(x, doys)
26+
assert out.shape == (batch_size, 2, embed_dim)
27+
28+
29+
def test_avg_pool_tokenizer_channel_means_eos_anchored():
30+
patch_size = 10
31+
tokenizer = AvgPoolTokenizer(in_dim=1, embed_dim=1, patch_size=patch_size)
32+
x = torch.arange(25, dtype=torch.float32).view(1, 25, 1)
33+
doys = torch.zeros(1, 25, dtype=torch.int16)
34+
35+
out = tokenizer(x, doys)
36+
37+
assert out.shape == (1, 2, 1)
38+
assert out[0, 0, 0].item() == pytest.approx(torch.arange(5, 15).float().mean().item())
39+
assert out[0, 1, 0].item() == pytest.approx(torch.arange(15, 25).float().mean().item())
40+
41+
42+
def test_avg_pool_tokenizer_has_no_trainable_parameters():
43+
tokenizer = AvgPoolTokenizer(in_dim=4, embed_dim=8, patch_size=10)
44+
assert sum(p.numel() for p in tokenizer.parameters()) == 0
45+
46+
47+
def test_avg_pool_tokenizer_pads_channels_to_embed_dim():
48+
tokenizer = AvgPoolTokenizer(in_dim=3, embed_dim=5, patch_size=5)
49+
x = torch.ones(1, 10, 3)
50+
out = tokenizer(x, torch.zeros(1, 10, dtype=torch.int16))
51+
52+
assert out.shape == (1, 2, 5)
53+
assert torch.all(out[..., 3:] == 0)
54+
55+
56+
def test_avg_pool_tokenizer_rejects_short_sequence():
57+
tokenizer = AvgPoolTokenizer(in_dim=2, embed_dim=2, patch_size=10)
58+
x = torch.randn(1, 8, 2)
59+
with pytest.raises(ValueError, match="shorter than patch_size"):
60+
tokenizer(x, torch.zeros(1, 8, dtype=torch.int16))
61+
62+
63+
def test_avg_pool_temporal_encoder_end_to_end():
64+
batch_size, seq_len, in_dim, embed_dim = 2, 30, 6, 6
65+
encoder = TemporalEncoder(
66+
tokenizer=AvgPoolTokenizer(in_dim=in_dim, embed_dim=embed_dim, patch_size=10),
67+
processor=LSTMProcessor(embed_dim=embed_dim, num_layers=1, dropout=0.0),
68+
pooling=LastStepPooling(),
69+
embed_dim=embed_dim,
70+
)
71+
temporal = torch.randn(batch_size, seq_len, in_dim)
72+
doys = torch.randint(1, 366, (batch_size, seq_len))
73+
74+
out = encoder(temporal, doys)
75+
assert out.shape == (batch_size, embed_dim)
76+
77+
78+
def test_lstm_lf_with_avg_pool_tokenizer_runs_on_dataset():
79+
overrides = [
80+
"dataset/crop=wheat",
81+
"dataset.country=NL",
82+
"dataset.framework=torch",
83+
"dataset.use_cache=false",
84+
"model=lstm_lf",
85+
"experiment.device=cpu",
86+
"model.epochs=1",
87+
"model/torch_model/temporal_encoder/tokenizer=avg_pool",
88+
"model.torch_model.embed_dim=8",
89+
"dataset/temporal=no_aggregate",
90+
"~dataset.temporal.sources.ndvi",
91+
"~dataset.temporal.sources.soil_moisture",
92+
"dataset.temporal.sources.meteo.select=[tmin,tmax,tavg,prec,rad,et0,vpd]",
93+
]
94+
with initialize(version_base=None, config_path="../../cybench/conf"):
95+
cfg = compose(config_name="config", overrides=overrides)
96+
97+
dataset = DataFactory(cfg.dataset).build()
98+
cfg.model = adjust_model_cfg_to_dataset(cfg.model, dataset)
99+
model_cfg = remove_keys(cfg.model, "_search_")
100+
model = instantiate(model_cfg)
101+
102+
even_years = {year for year in dataset.years if year % 2 == 0}
103+
odd_years = dataset.years - even_years
104+
train_dataset, test_dataset = dataset.split_on_years((even_years, odd_years))
105+
106+
model.fit(train_dataset)
107+
test_preds, _ = model.predict(test_dataset)
108+
assert test_preds.shape[0] == len(test_dataset)
109+
110+
targets = test_dataset.targets
111+
evaluation_result = evaluate_predictions(targets, test_preds, cfg.evaluation)
112+
assert "normalized_rmse" in evaluation_result

0 commit comments

Comments
 (0)