ARotting commited on
Commit
6c77856
·
verified ·
1 Parent(s): 9f873b6

Publish Continuous-decay recurrent model and matched controls

Browse files
README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Liquid Time Pocket
3
+ emoji: 💧
4
+ colorFrom: cyan
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: "6.5.1"
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # Liquid Time Pocket
13
+
14
+ Liquid Time Pocket trains a recurrent cell whose hidden state relaxes toward a
15
+ learned candidate through per-unit continuous time constants. The GRU and plain
16
+ RNN controls contain exactly the same 1,887 parameters and receive delta time as
17
+ an ordinary input feature.
18
+
19
+ All models train with gaps from 0.02 to 0.12, then face unseen gaps up to 0.40 on
20
+ twice-longer sequences. The Space plots predictions against continuous timestamps.
21
+
22
+ ## Verified local result
23
+
24
+ At exactly 1,887 parameters, normal-gap RMSE was tightly matched: 0.0893 for the
25
+ liquid cell, 0.0869 for the GRU, and 0.0881 for the RNN. Under unseen gaps up to
26
+ 0.40, the liquid cell failed at 1.514 RMSE versus 0.274/0.281 for GRU/RNN. Its
27
+ learned time constants ranged from 0.122 to 0.716, causing excessive phase-memory
28
+ decay under the shifted interval distribution.
29
+
30
+ ```bash
31
+ uv run python projects/liquid-time-pocket/train.py
32
+ uv run pytest tests/test_liquid_time_pocket.py
33
+ ```
evaluation.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "experiment": "Liquid time-constant irregular forecasting",
3
+ "learned_time_constants": {
4
+ "minimum": 0.12157730013132095,
5
+ "mean": 0.45884114503860474,
6
+ "maximum": 0.7155194282531738
7
+ },
8
+ "results": {
9
+ "liquid_time_constant": {
10
+ "parameters": 1887,
11
+ "best_step": 2500,
12
+ "normal_gaps": {
13
+ "rmse": 0.08929167687892914,
14
+ "mae": 0.03883583843708038,
15
+ "examples": 2000,
16
+ "sequence_length": 128,
17
+ "delta_time_range": [
18
+ 0.02,
19
+ 0.12
20
+ ]
21
+ },
22
+ "unseen_large_gaps": {
23
+ "rmse": 1.5136159658432007,
24
+ "mae": 1.1862934827804565,
25
+ "examples": 2000,
26
+ "sequence_length": 128,
27
+ "delta_time_range": [
28
+ 0.12,
29
+ 0.4
30
+ ]
31
+ }
32
+ },
33
+ "matched_gru": {
34
+ "parameters": 1887,
35
+ "best_step": 2500,
36
+ "normal_gaps": {
37
+ "rmse": 0.08689922839403152,
38
+ "mae": 0.040582284331321716,
39
+ "examples": 2000,
40
+ "sequence_length": 128,
41
+ "delta_time_range": [
42
+ 0.02,
43
+ 0.12
44
+ ]
45
+ },
46
+ "unseen_large_gaps": {
47
+ "rmse": 0.27418622374534607,
48
+ "mae": 0.21493969857692719,
49
+ "examples": 2000,
50
+ "sequence_length": 128,
51
+ "delta_time_range": [
52
+ 0.12,
53
+ 0.4
54
+ ]
55
+ }
56
+ },
57
+ "matched_rnn": {
58
+ "parameters": 1887,
59
+ "best_step": 2500,
60
+ "normal_gaps": {
61
+ "rmse": 0.08811825513839722,
62
+ "mae": 0.04227612167596817,
63
+ "examples": 2000,
64
+ "sequence_length": 128,
65
+ "delta_time_range": [
66
+ 0.02,
67
+ 0.12
68
+ ]
69
+ },
70
+ "unseen_large_gaps": {
71
+ "rmse": 0.2811814546585083,
72
+ "mae": 0.21727129817008972,
73
+ "examples": 2000,
74
+ "sequence_length": 128,
75
+ "delta_time_range": [
76
+ 0.12,
77
+ 0.4
78
+ ]
79
+ }
80
+ }
81
+ }
82
+ }
liquid_time_constant.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:85ae104f7e94283395f785b43d487adb5364fbe5a490cd5731129c90df8a2160
3
+ size 7924
matched_gru.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:780ad589ab98f153dce68d555a80fac1bfeeaedcc4c04fc8a392c89ca8e98654
3
+ size 8012
matched_rnn.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6389efac3ce3bfc7ad1c6f0373eb1fb82b43e17ca489ef5808e19e95c81f5ea6
3
+ size 8012
source/app.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+ import plotly.graph_objects as go
8
+ import torch
9
+ from data import irregular_batch
10
+ from model import LiquidTimeConstantRNN, MatchedGRU, MatchedRNN
11
+ from safetensors.torch import load_file
12
+
13
+ ARTIFACT_DIR = Path(__file__).resolve().parent / "artifacts" / "liquid-time-pocket"
14
+ REPORT = json.loads((ARTIFACT_DIR / "evaluation.json").read_text(encoding="utf-8"))
15
+ MODELS = {
16
+ "Liquid time constant": (LiquidTimeConstantRNN(), "liquid_time_constant"),
17
+ "Matched GRU": (MatchedGRU(), "matched_gru"),
18
+ "Matched RNN": (MatchedRNN(), "matched_rnn"),
19
+ }
20
+ for model, key in MODELS.values():
21
+ model.load_state_dict(load_file(ARTIFACT_DIR / f"{key}.safetensors"))
22
+ model.eval()
23
+
24
+
25
+ @torch.inference_mode()
26
+ def forecast(seed: int, large_gaps: bool) -> tuple[go.Figure, dict]:
27
+ inputs, targets, time = irregular_batch(
28
+ 1, 128, int(seed), large_gaps=bool(large_gaps)
29
+ )
30
+ figure = go.Figure()
31
+ figure.add_trace(
32
+ go.Scatter(x=time[0], y=targets[0, :, 0], name="Target", line={"width": 4})
33
+ )
34
+ metrics = {}
35
+ for label, (model, key) in MODELS.items():
36
+ prediction = model(inputs)[0, :, 0]
37
+ figure.add_trace(go.Scatter(x=time[0], y=prediction, name=label))
38
+ metrics[label] = {
39
+ "live_rmse": float(
40
+ (prediction - targets[0, :, 0]).square().mean().sqrt()
41
+ ),
42
+ "verified_large_gap_rmse": REPORT["results"][key][
43
+ "unseen_large_gaps"
44
+ ]["rmse"],
45
+ }
46
+ figure.update_layout(
47
+ template="plotly_dark",
48
+ title="Irregularly sampled multiscale forecast",
49
+ xaxis_title="Continuous time",
50
+ yaxis_title="Signal",
51
+ )
52
+ return figure, metrics
53
+
54
+
55
+ with gr.Blocks(title="Liquid Time Pocket") as demo:
56
+ gr.Markdown(
57
+ "# Liquid Time Pocket\n"
58
+ "Compare a learned continuous decay cell with exactly parameter-matched "
59
+ "GRU and RNN controls under irregular time gaps."
60
+ )
61
+ with gr.Row():
62
+ seed = gr.Slider(0, 100_000, value=2203, step=1, label="Task seed")
63
+ large_gaps = gr.Checkbox(True, label="Use unseen large time gaps")
64
+ initial = forecast(2203, True)
65
+ chart = gr.Plot(value=initial[0])
66
+ metrics = gr.JSON(value=initial[1])
67
+ button = gr.Button("Forecast irregular telemetry", variant="primary")
68
+ button.click(forecast, inputs=[seed, large_gaps], outputs=[chart, metrics])
69
+
70
+
71
+ if __name__ == "__main__":
72
+ demo.launch()
73
+
source/data.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+
6
+
7
+ def irregular_batch(
8
+ batch: int,
9
+ length: int,
10
+ seed: int,
11
+ *,
12
+ large_gaps: bool,
13
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
14
+ rng = np.random.default_rng(seed)
15
+ low, high = ((0.12, 0.40) if large_gaps else (0.02, 0.12))
16
+ delta_time = rng.uniform(low, high, size=(batch, length))
17
+ time = np.cumsum(delta_time, axis=1)
18
+ amplitudes = rng.uniform(0.5, 1.5, size=(batch, 3, 1))
19
+ frequencies = rng.uniform(
20
+ np.array([0.7, 1.7, 3.5])[None, :, None],
21
+ np.array([1.1, 2.3, 5.0])[None, :, None],
22
+ size=(batch, 3, 1),
23
+ )
24
+ phases = rng.uniform(0, 2 * np.pi, size=(batch, 3, 1))
25
+ values = (
26
+ amplitudes
27
+ * np.sin(frequencies * time[:, None, :] + phases)
28
+ * np.array([1.0, 0.5, 0.2])[None, :, None]
29
+ ).sum(axis=1)
30
+ values += rng.normal(0, 0.01, values.shape)
31
+ current = np.concatenate([np.zeros((batch, 1)), values[:, :-1]], axis=1)
32
+ inputs = np.stack([current, delta_time], axis=2).astype(np.float32)
33
+ return (
34
+ torch.from_numpy(inputs),
35
+ torch.from_numpy(values.astype(np.float32)).unsqueeze(2),
36
+ torch.from_numpy(time.astype(np.float32)),
37
+ )
38
+
source/model.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class LiquidTimeConstantRNN(nn.Module):
8
+ def __init__(self, hidden_dimensions: int = 41) -> None:
9
+ super().__init__()
10
+ self.hidden_dimensions = hidden_dimensions
11
+ self.candidate = nn.Linear(hidden_dimensions + 2, hidden_dimensions)
12
+ self.log_time_constant = nn.Parameter(torch.zeros(hidden_dimensions))
13
+ self.output = nn.Linear(hidden_dimensions, 1)
14
+
15
+ def forward(self, sequence: torch.Tensor) -> torch.Tensor:
16
+ hidden = torch.zeros(
17
+ len(sequence),
18
+ self.hidden_dimensions,
19
+ device=sequence.device,
20
+ )
21
+ time_constant = torch.nn.functional.softplus(self.log_time_constant) + 0.03
22
+ outputs = []
23
+ for step in range(sequence.shape[1]):
24
+ value_and_dt = sequence[:, step]
25
+ candidate_inputs = torch.cat(
26
+ [
27
+ value_and_dt[:, :1],
28
+ value_and_dt[:, 1:2].clamp(max=0.12),
29
+ ],
30
+ dim=1,
31
+ )
32
+ candidate = torch.tanh(
33
+ self.candidate(torch.cat([candidate_inputs, hidden], dim=1))
34
+ )
35
+ delta_time = value_and_dt[:, 1:2]
36
+ decay = torch.exp(-delta_time / time_constant[None])
37
+ hidden = decay * hidden + (1 - decay) * candidate
38
+ outputs.append(self.output(hidden))
39
+ return torch.stack(outputs, dim=1)
40
+
41
+
42
+ class MatchedGRU(nn.Module):
43
+ def __init__(self, hidden_dimensions: int = 23) -> None:
44
+ super().__init__()
45
+ self.recurrent = nn.GRU(2, hidden_dimensions, batch_first=True)
46
+ self.output = nn.Linear(hidden_dimensions, 1)
47
+
48
+ def forward(self, sequence: torch.Tensor) -> torch.Tensor:
49
+ hidden, _ = self.recurrent(sequence)
50
+ return self.output(hidden)
51
+
52
+
53
+ class MatchedRNN(nn.Module):
54
+ def __init__(self, hidden_dimensions: int = 41) -> None:
55
+ super().__init__()
56
+ self.recurrent = nn.RNN(2, hidden_dimensions, batch_first=True)
57
+ self.output = nn.Linear(hidden_dimensions, 1)
58
+
59
+ def forward(self, sequence: torch.Tensor) -> torch.Tensor:
60
+ hidden, _ = self.recurrent(sequence)
61
+ return self.output(hidden)
62
+
63
+
64
+ def parameter_count(model: nn.Module) -> int:
65
+ return sum(parameter.numel() for parameter in model.parameters())
source/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=5,<7
2
+ plotly>=6,<7
3
+ safetensors>=0.6,<1
4
+ torch>=2.7,<3
5
+
source/train.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import torch
8
+ import trackio
9
+ from data import irregular_batch
10
+ from model import (
11
+ LiquidTimeConstantRNN,
12
+ MatchedGRU,
13
+ MatchedRNN,
14
+ parameter_count,
15
+ )
16
+ from safetensors.torch import save_file
17
+ from torch.nn import functional as F
18
+
19
+ PROJECT_DIR = Path(__file__).resolve().parent
20
+ ARTIFACT_DIR = PROJECT_DIR / "artifacts" / "liquid-time-pocket"
21
+ DATA_DIR = PROJECT_DIR / "data"
22
+ SEED = 2203
23
+
24
+
25
+ @torch.inference_mode()
26
+ def evaluate(model: torch.nn.Module, *, large_gaps: bool) -> dict:
27
+ inputs, targets, _ = irregular_batch(
28
+ 2_000,
29
+ 128,
30
+ SEED + 10_000 + int(large_gaps),
31
+ large_gaps=large_gaps,
32
+ )
33
+ prediction = model(inputs)
34
+ error = prediction - targets
35
+ return {
36
+ "rmse": float(error.square().mean().sqrt()),
37
+ "mae": float(error.abs().mean()),
38
+ "examples": len(inputs),
39
+ "sequence_length": inputs.shape[1],
40
+ "delta_time_range": [0.12, 0.40] if large_gaps else [0.02, 0.12],
41
+ }
42
+
43
+
44
+ def train_variant(name: str, model: torch.nn.Module) -> tuple[torch.nn.Module, int]:
45
+ optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-5)
46
+ best = float("inf")
47
+ best_step = 0
48
+ best_state = None
49
+ for step in range(1, 2_501):
50
+ inputs, targets, _ = irregular_batch(
51
+ 96, 64, SEED + step, large_gaps=False
52
+ )
53
+ prediction = model(inputs)
54
+ loss = F.mse_loss(prediction, targets)
55
+ optimizer.zero_grad(set_to_none=True)
56
+ loss.backward()
57
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5)
58
+ optimizer.step()
59
+ if step % 125 == 0:
60
+ validation = evaluate(model, large_gaps=False)
61
+ trackio.log(
62
+ {
63
+ "variant": name,
64
+ "training_step": step,
65
+ "training_mse": float(loss.detach()),
66
+ "validation_rmse": validation["rmse"],
67
+ }
68
+ )
69
+ if validation["rmse"] < best:
70
+ best = validation["rmse"]
71
+ best_step = step
72
+ best_state = {
73
+ key: value.detach().cpu().clone()
74
+ for key, value in model.state_dict().items()
75
+ }
76
+ assert best_state is not None
77
+ model.load_state_dict(best_state)
78
+ return model, best_step
79
+
80
+
81
+ def main() -> None:
82
+ torch.manual_seed(SEED)
83
+ torch.set_num_threads(1)
84
+ models = {
85
+ "liquid_time_constant": LiquidTimeConstantRNN(),
86
+ "matched_gru": MatchedGRU(),
87
+ "matched_rnn": MatchedRNN(),
88
+ }
89
+ assert {parameter_count(model) for model in models.values()} == {1_887}
90
+ trackio.init(
91
+ project="liquid-time-pocket",
92
+ name="irregular-gap-ltc-v1",
93
+ config={
94
+ "parameters_per_model": 1_887,
95
+ "training_steps": 2_500,
96
+ "training_delta_time": [0.02, 0.12],
97
+ "unseen_delta_time": [0.12, 0.40],
98
+ },
99
+ )
100
+ results = {}
101
+ ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
102
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
103
+ for name, model in models.items():
104
+ model, best_step = train_variant(name, model)
105
+ results[name] = {
106
+ "parameters": parameter_count(model),
107
+ "best_step": best_step,
108
+ "normal_gaps": evaluate(model, large_gaps=False),
109
+ "unseen_large_gaps": evaluate(model, large_gaps=True),
110
+ }
111
+ save_file(model.state_dict(), ARTIFACT_DIR / f"{name}.safetensors")
112
+ inputs, targets, time = irregular_batch(
113
+ 100, 128, SEED + 30_000, large_gaps=True
114
+ )
115
+ frame = {
116
+ "task": [],
117
+ "time": [],
118
+ "target": [],
119
+ **{name: [] for name in models},
120
+ }
121
+ with torch.inference_mode():
122
+ predictions = {name: model(inputs) for name, model in models.items()}
123
+ for task in range(len(inputs)):
124
+ for step in range(inputs.shape[1]):
125
+ frame["task"].append(task)
126
+ frame["time"].append(float(time[task, step]))
127
+ frame["target"].append(float(targets[task, step, 0]))
128
+ for name in models:
129
+ frame[name].append(float(predictions[name][task, step, 0]))
130
+ report = {
131
+ "experiment": "Liquid time-constant irregular forecasting",
132
+ "learned_time_constants": {
133
+ "minimum": float(
134
+ (F.softplus(models["liquid_time_constant"].log_time_constant) + 0.03)
135
+ .min()
136
+ ),
137
+ "mean": float(
138
+ (F.softplus(models["liquid_time_constant"].log_time_constant) + 0.03)
139
+ .mean()
140
+ ),
141
+ "maximum": float(
142
+ (F.softplus(models["liquid_time_constant"].log_time_constant) + 0.03)
143
+ .max()
144
+ ),
145
+ },
146
+ "results": results,
147
+ }
148
+ (ARTIFACT_DIR / "evaluation.json").write_text(
149
+ json.dumps(report, indent=2), encoding="utf-8"
150
+ )
151
+ pd.DataFrame(frame).to_parquet(DATA_DIR / "large_gap_forecasts.parquet", index=False)
152
+ trackio.log(
153
+ {
154
+ f"{name}_large_gap_rmse": result["unseen_large_gaps"]["rmse"]
155
+ for name, result in results.items()
156
+ }
157
+ )
158
+ trackio.finish()
159
+ print(json.dumps(report, indent=2))
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()