latentplant 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- latentplant-0.4.0/LICENSE +21 -0
- latentplant-0.4.0/PKG-INFO +187 -0
- latentplant-0.4.0/README.md +158 -0
- latentplant-0.4.0/pyproject.toml +47 -0
- latentplant-0.4.0/setup.cfg +4 -0
- latentplant-0.4.0/src/latentplant/__init__.py +69 -0
- latentplant-0.4.0/src/latentplant/calibrate.py +187 -0
- latentplant-0.4.0/src/latentplant/data/__init__.py +26 -0
- latentplant-0.4.0/src/latentplant/data/contract.py +212 -0
- latentplant-0.4.0/src/latentplant/data/windows.py +231 -0
- latentplant-0.4.0/src/latentplant/export.py +264 -0
- latentplant-0.4.0/src/latentplant/metrics/__init__.py +27 -0
- latentplant-0.4.0/src/latentplant/metrics/rollout.py +171 -0
- latentplant-0.4.0/src/latentplant/models/__init__.py +7 -0
- latentplant-0.4.0/src/latentplant/models/base.py +131 -0
- latentplant-0.4.0/src/latentplant/models/ensemble.py +251 -0
- latentplant-0.4.0/src/latentplant/models/rssm.py +345 -0
- latentplant-0.4.0/src/latentplant/models/rssm_ensemble.py +217 -0
- latentplant-0.4.0/src/latentplant/planning.py +294 -0
- latentplant-0.4.0/src/latentplant.egg-info/PKG-INFO +187 -0
- latentplant-0.4.0/src/latentplant.egg-info/SOURCES.txt +28 -0
- latentplant-0.4.0/src/latentplant.egg-info/dependency_links.txt +1 -0
- latentplant-0.4.0/src/latentplant.egg-info/requires.txt +14 -0
- latentplant-0.4.0/src/latentplant.egg-info/top_level.txt +1 -0
- latentplant-0.4.0/tests/test_calibration_and_planning.py +392 -0
- latentplant-0.4.0/tests/test_contract_and_windows.py +192 -0
- latentplant-0.4.0/tests/test_ensemble_world_model.py +165 -0
- latentplant-0.4.0/tests/test_metrics.py +121 -0
- latentplant-0.4.0/tests/test_onnx_export.py +149 -0
- latentplant-0.4.0/tests/test_rssm.py +204 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Felipe Santibanez-Leal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: latentplant
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Learned world models for industrial process time series: action-conditioned latent dynamics, imagination rollouts with calibrated uncertainty, planning and regime-change detection
|
|
5
|
+
Author-email: Felipe Santibanez-Leal <fsantibanez@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/fsantibanezleal/CAOS_LatentPlant
|
|
8
|
+
Keywords: world-models,latent-dynamics,model-based-rl,imagination,process-control,time-series,uncertainty-quantification,mineral-processing,rssm,planning
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Requires-Dist: torch>=2.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
22
|
+
Provides-Extra: onnx
|
|
23
|
+
Requires-Dist: onnx>=1.16; extra == "onnx"
|
|
24
|
+
Requires-Dist: onnxruntime>=1.18; extra == "onnx"
|
|
25
|
+
Provides-Extra: baselines
|
|
26
|
+
Requires-Dist: sysidentpy>=0.3; extra == "baselines"
|
|
27
|
+
Requires-Dist: pysindy>=1.7; extra == "baselines"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# latentplant
|
|
31
|
+
|
|
32
|
+
[](https://github.com/fsantibanezleal/CAOS_LatentPlant/actions/workflows/ci.yml)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
**Learned world models for industrial process time series.** An action-conditioned model of
|
|
36
|
+
process dynamics that can be rolled forward in imagination, carries calibrated uncertainty, and
|
|
37
|
+
supports what-if queries, planning and regime-change detection.
|
|
38
|
+
|
|
39
|
+
## What a world model is here, and why it is not a forecaster
|
|
40
|
+
|
|
41
|
+
A forecaster maps history to a future. A world model maps history **and a proposed action
|
|
42
|
+
sequence** to a distribution over futures:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
rollout(obs_ctx, act_ctx, exo_ctx, act_fut, exo_fut, n_samples=64)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`act_fut` is an argument. That single difference is what makes counterfactual questions
|
|
49
|
+
expressible ("what if we raise the amine dose for the next four hours") and what makes planning
|
|
50
|
+
possible at all. A model that ignores its action input is a forecaster wearing a costume, so the
|
|
51
|
+
test suite asserts direction-of-effect on a system with known gain.
|
|
52
|
+
|
|
53
|
+
## The disciplines baked in
|
|
54
|
+
|
|
55
|
+
**Rollout-first evaluation.** One-step teacher-forced error is not a world-model metric: on any
|
|
56
|
+
autocorrelated process series it flatters a model that has learned nothing. Everything in
|
|
57
|
+
`latentplant.metrics` is indexed by horizon, and `break_even_horizon` reports where the model
|
|
58
|
+
stops beating persistence. That integer belongs next to every headline number.
|
|
59
|
+
|
|
60
|
+
**Two uncertainties, reported separately.** `uncertainty_split` returns aleatoric (process and
|
|
61
|
+
sensor noise, irreducible) and epistemic (the model does not know this region) as different
|
|
62
|
+
arrays. A noisy tag and an unfamiliar operating point look identical in a single band and mean
|
|
63
|
+
completely different things; the epistemic band is what a regime-change alarm should watch.
|
|
64
|
+
|
|
65
|
+
**Cadence is declared, not guessed.** An hourly lab assay carried on 20-second rows repeats about
|
|
66
|
+
180 times. A model trained without masking those repeats learns to copy the previous row and
|
|
67
|
+
scores beautifully. `PlantSchema` takes `update_seconds` per tag and `stale_mask` marks the
|
|
68
|
+
repeats so the loss can ignore them.
|
|
69
|
+
|
|
70
|
+
**Leakage-safe by construction.** Episodes never cross an acquisition gap, windows never cross an
|
|
71
|
+
episode, splits are chronological, and standardization is fitted on train only.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install latentplant # core: numpy + torch
|
|
77
|
+
pip install "latentplant[onnx]" # ONNX export for a browser inference lane
|
|
78
|
+
pip install "latentplant[baselines]" # sysidentpy / pysindy baseline adapters
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import numpy as np
|
|
85
|
+
from latentplant import (PlantSchema, TagSpec, ProbabilisticEnsemble,
|
|
86
|
+
split_on_gaps, make_windows, chronological_split)
|
|
87
|
+
from latentplant.metrics import rollout_report
|
|
88
|
+
|
|
89
|
+
schema = PlantSchema(
|
|
90
|
+
tags=(
|
|
91
|
+
TagSpec("silica_conc", "target", "pct", 0.0, 10.0, update_seconds=3600.0),
|
|
92
|
+
TagSpec("pulp_level", "observation", "pct", 0.0, 100.0),
|
|
93
|
+
TagSpec("amina_flow", "action", "m3/h", 0.0, 800.0),
|
|
94
|
+
TagSpec("iron_feed", "exogenous", "pct"),
|
|
95
|
+
),
|
|
96
|
+
row_seconds=20.0,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
episodes = split_on_gaps(schema, values, timestamps) # a plant stop ends an episode
|
|
100
|
+
windows = make_windows(schema, episodes, context=24, horizon=12)
|
|
101
|
+
train, val, test = chronological_split(windows)
|
|
102
|
+
|
|
103
|
+
wm = ProbabilisticEnsemble(n_obs=2, n_act=1, n_exo=1, n_members=5)
|
|
104
|
+
wm.fit(train, epochs=60, seed=0)
|
|
105
|
+
|
|
106
|
+
roll = wm.rollout(test.obs_ctx, test.act_ctx, test.exo_ctx,
|
|
107
|
+
test.act_fut, test.exo_fut, n_samples=64, seed=0)
|
|
108
|
+
lo, hi = roll.interval(0.9)
|
|
109
|
+
print(rollout_report(roll.samples, test.obs_fut, test.obs_ctx, test.mask_fut))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Counterfactual A/B from the same anchor:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
more, less = wm.counterfactual(ctx_obs, ctx_act, ctx_exo, action_a, action_b, exo_fut)
|
|
116
|
+
lift = more.mean - less.mean
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
On real plant data this supports direction-of-effect claims only: logged actions come from a
|
|
120
|
+
closed loop, so observational data does not identify interventions without assumptions. On a
|
|
121
|
+
simulator the ground truth exists and the imagination-to-reality gap is measured instead.
|
|
122
|
+
|
|
123
|
+
Plan against the model, and measure what the plan was worth:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from latentplant import Planner, cvar_cost, target_tracking_cost, imagination_gap
|
|
127
|
+
|
|
128
|
+
planner = Planner(model=wm, bounds=action_bounds, horizon=24, method="cem")
|
|
129
|
+
plan = planner.plan(obs_ctx[:1], act_ctx[:1], exo_ctx[:1], exo_fut[:1],
|
|
130
|
+
cvar_cost(target_tracking_cost(target), alpha=0.2),
|
|
131
|
+
action_rate_penalty=2.0)
|
|
132
|
+
|
|
133
|
+
realized = env.execute(plan.actions) # only possible where truth exists
|
|
134
|
+
print(imagination_gap(plan, realized, persistence_from=obs_ctx[0]))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Fix an overconfident model's intervals without retraining it:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from latentplant import fit_conformal, calibration_report
|
|
141
|
+
|
|
142
|
+
conf = fit_conformal(cal_roll, cal.obs_fut, level=0.9, mask=cal.mask_fut)
|
|
143
|
+
print(calibration_report(conf, test_roll, test.obs_fut, mask=test.mask_fut))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Documentation
|
|
147
|
+
|
|
148
|
+
Full docs in [`docs/`](docs/README.md): concepts (what a world model is, the plant contract,
|
|
149
|
+
windows and leakage), models (probabilistic ensemble, RSSM, ensembled RSSM), evaluation (rollout
|
|
150
|
+
metrics, conformal calibration) and planning (imagination, the imagination-to-reality gap).
|
|
151
|
+
|
|
152
|
+
## Status
|
|
153
|
+
|
|
154
|
+
0.03.000, alpha. Shipping now: the ingestion contract with the multi-rate observation operator,
|
|
155
|
+
episodes/windows/leakage-safe splits, the probabilistic ensemble (PETS-class, TS-inf
|
|
156
|
+
propagation), the vector RSSM, the ensembled RSSM with the aleatoric/epistemic split, the
|
|
157
|
+
rollout metric suite, split-conformal interval calibration, and CEM/MPPI planning with the
|
|
158
|
+
imagination-to-reality gap. On the roadmap: ONNX export for a browser inference lane, a
|
|
159
|
+
Gymnasium imagination env, and baseline adapters.
|
|
160
|
+
|
|
161
|
+
## Not to be confused with
|
|
162
|
+
|
|
163
|
+
[`phenoforge`](https://pypi.org/project/phenoforge/), the sibling engine, which fits and
|
|
164
|
+
ensembles **closed-form phenomenological equations**. `latentplant` **learns latent dynamics from
|
|
165
|
+
data**. Same industry, different object.
|
|
166
|
+
|
|
167
|
+
## References
|
|
168
|
+
|
|
169
|
+
The design follows: Chua et al. 2018, *Deep RL in a Handful of Trials with Probabilistic Dynamics
|
|
170
|
+
Models*, [arXiv:1805.12114](https://arxiv.org/abs/1805.12114) (probabilistic ensembles, TS-inf);
|
|
171
|
+
Lakshminarayanan et al. 2017, *Simple and Scalable Predictive Uncertainty Estimation using Deep
|
|
172
|
+
Ensembles*, [arXiv:1612.01474](https://arxiv.org/abs/1612.01474); Hafner et al. 2019, *Learning
|
|
173
|
+
Latent Dynamics for Planning from Pixels*, [arXiv:1811.04551](https://arxiv.org/abs/1811.04551)
|
|
174
|
+
(RSSM); Hafner et al. 2023, *Mastering Diverse Domains through World Models*,
|
|
175
|
+
[arXiv:2301.04104](https://arxiv.org/abs/2301.04104); Janner et al. 2019, *When to Trust Your
|
|
176
|
+
Model*, [arXiv:1906.08253](https://arxiv.org/abs/1906.08253) (rollout horizon and compounding
|
|
177
|
+
error); Che et al. 2016, *Recurrent Neural Networks for Multivariate Time Series with Missing
|
|
178
|
+
Values*, [arXiv:1606.01865](https://arxiv.org/abs/1606.01865) (cadence and masking); Romano et
|
|
179
|
+
al. 2019, *Conformalized Quantile Regression*,
|
|
180
|
+
[arXiv:1905.03222](https://arxiv.org/abs/1905.03222) (interval calibration); Williams et al.
|
|
181
|
+
2017, *Information Theoretic Model Predictive Control*,
|
|
182
|
+
[arXiv:1707.02342](https://arxiv.org/abs/1707.02342) (MPPI); Levine et al. 2020, *Offline
|
|
183
|
+
Reinforcement Learning: Tutorial, Review, and Perspectives*,
|
|
184
|
+
[arXiv:2005.01643](https://arxiv.org/abs/2005.01643) (why the planner refuses to issue
|
|
185
|
+
setpoints).
|
|
186
|
+
|
|
187
|
+
Developed by Felipe Santibanez-Leal. MIT licensed.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# latentplant
|
|
2
|
+
|
|
3
|
+
[](https://github.com/fsantibanezleal/CAOS_LatentPlant/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Learned world models for industrial process time series.** An action-conditioned model of
|
|
7
|
+
process dynamics that can be rolled forward in imagination, carries calibrated uncertainty, and
|
|
8
|
+
supports what-if queries, planning and regime-change detection.
|
|
9
|
+
|
|
10
|
+
## What a world model is here, and why it is not a forecaster
|
|
11
|
+
|
|
12
|
+
A forecaster maps history to a future. A world model maps history **and a proposed action
|
|
13
|
+
sequence** to a distribution over futures:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
rollout(obs_ctx, act_ctx, exo_ctx, act_fut, exo_fut, n_samples=64)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`act_fut` is an argument. That single difference is what makes counterfactual questions
|
|
20
|
+
expressible ("what if we raise the amine dose for the next four hours") and what makes planning
|
|
21
|
+
possible at all. A model that ignores its action input is a forecaster wearing a costume, so the
|
|
22
|
+
test suite asserts direction-of-effect on a system with known gain.
|
|
23
|
+
|
|
24
|
+
## The disciplines baked in
|
|
25
|
+
|
|
26
|
+
**Rollout-first evaluation.** One-step teacher-forced error is not a world-model metric: on any
|
|
27
|
+
autocorrelated process series it flatters a model that has learned nothing. Everything in
|
|
28
|
+
`latentplant.metrics` is indexed by horizon, and `break_even_horizon` reports where the model
|
|
29
|
+
stops beating persistence. That integer belongs next to every headline number.
|
|
30
|
+
|
|
31
|
+
**Two uncertainties, reported separately.** `uncertainty_split` returns aleatoric (process and
|
|
32
|
+
sensor noise, irreducible) and epistemic (the model does not know this region) as different
|
|
33
|
+
arrays. A noisy tag and an unfamiliar operating point look identical in a single band and mean
|
|
34
|
+
completely different things; the epistemic band is what a regime-change alarm should watch.
|
|
35
|
+
|
|
36
|
+
**Cadence is declared, not guessed.** An hourly lab assay carried on 20-second rows repeats about
|
|
37
|
+
180 times. A model trained without masking those repeats learns to copy the previous row and
|
|
38
|
+
scores beautifully. `PlantSchema` takes `update_seconds` per tag and `stale_mask` marks the
|
|
39
|
+
repeats so the loss can ignore them.
|
|
40
|
+
|
|
41
|
+
**Leakage-safe by construction.** Episodes never cross an acquisition gap, windows never cross an
|
|
42
|
+
episode, splits are chronological, and standardization is fitted on train only.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install latentplant # core: numpy + torch
|
|
48
|
+
pip install "latentplant[onnx]" # ONNX export for a browser inference lane
|
|
49
|
+
pip install "latentplant[baselines]" # sysidentpy / pysindy baseline adapters
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import numpy as np
|
|
56
|
+
from latentplant import (PlantSchema, TagSpec, ProbabilisticEnsemble,
|
|
57
|
+
split_on_gaps, make_windows, chronological_split)
|
|
58
|
+
from latentplant.metrics import rollout_report
|
|
59
|
+
|
|
60
|
+
schema = PlantSchema(
|
|
61
|
+
tags=(
|
|
62
|
+
TagSpec("silica_conc", "target", "pct", 0.0, 10.0, update_seconds=3600.0),
|
|
63
|
+
TagSpec("pulp_level", "observation", "pct", 0.0, 100.0),
|
|
64
|
+
TagSpec("amina_flow", "action", "m3/h", 0.0, 800.0),
|
|
65
|
+
TagSpec("iron_feed", "exogenous", "pct"),
|
|
66
|
+
),
|
|
67
|
+
row_seconds=20.0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
episodes = split_on_gaps(schema, values, timestamps) # a plant stop ends an episode
|
|
71
|
+
windows = make_windows(schema, episodes, context=24, horizon=12)
|
|
72
|
+
train, val, test = chronological_split(windows)
|
|
73
|
+
|
|
74
|
+
wm = ProbabilisticEnsemble(n_obs=2, n_act=1, n_exo=1, n_members=5)
|
|
75
|
+
wm.fit(train, epochs=60, seed=0)
|
|
76
|
+
|
|
77
|
+
roll = wm.rollout(test.obs_ctx, test.act_ctx, test.exo_ctx,
|
|
78
|
+
test.act_fut, test.exo_fut, n_samples=64, seed=0)
|
|
79
|
+
lo, hi = roll.interval(0.9)
|
|
80
|
+
print(rollout_report(roll.samples, test.obs_fut, test.obs_ctx, test.mask_fut))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Counterfactual A/B from the same anchor:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
more, less = wm.counterfactual(ctx_obs, ctx_act, ctx_exo, action_a, action_b, exo_fut)
|
|
87
|
+
lift = more.mean - less.mean
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
On real plant data this supports direction-of-effect claims only: logged actions come from a
|
|
91
|
+
closed loop, so observational data does not identify interventions without assumptions. On a
|
|
92
|
+
simulator the ground truth exists and the imagination-to-reality gap is measured instead.
|
|
93
|
+
|
|
94
|
+
Plan against the model, and measure what the plan was worth:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from latentplant import Planner, cvar_cost, target_tracking_cost, imagination_gap
|
|
98
|
+
|
|
99
|
+
planner = Planner(model=wm, bounds=action_bounds, horizon=24, method="cem")
|
|
100
|
+
plan = planner.plan(obs_ctx[:1], act_ctx[:1], exo_ctx[:1], exo_fut[:1],
|
|
101
|
+
cvar_cost(target_tracking_cost(target), alpha=0.2),
|
|
102
|
+
action_rate_penalty=2.0)
|
|
103
|
+
|
|
104
|
+
realized = env.execute(plan.actions) # only possible where truth exists
|
|
105
|
+
print(imagination_gap(plan, realized, persistence_from=obs_ctx[0]))
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Fix an overconfident model's intervals without retraining it:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from latentplant import fit_conformal, calibration_report
|
|
112
|
+
|
|
113
|
+
conf = fit_conformal(cal_roll, cal.obs_fut, level=0.9, mask=cal.mask_fut)
|
|
114
|
+
print(calibration_report(conf, test_roll, test.obs_fut, mask=test.mask_fut))
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
Full docs in [`docs/`](docs/README.md): concepts (what a world model is, the plant contract,
|
|
120
|
+
windows and leakage), models (probabilistic ensemble, RSSM, ensembled RSSM), evaluation (rollout
|
|
121
|
+
metrics, conformal calibration) and planning (imagination, the imagination-to-reality gap).
|
|
122
|
+
|
|
123
|
+
## Status
|
|
124
|
+
|
|
125
|
+
0.03.000, alpha. Shipping now: the ingestion contract with the multi-rate observation operator,
|
|
126
|
+
episodes/windows/leakage-safe splits, the probabilistic ensemble (PETS-class, TS-inf
|
|
127
|
+
propagation), the vector RSSM, the ensembled RSSM with the aleatoric/epistemic split, the
|
|
128
|
+
rollout metric suite, split-conformal interval calibration, and CEM/MPPI planning with the
|
|
129
|
+
imagination-to-reality gap. On the roadmap: ONNX export for a browser inference lane, a
|
|
130
|
+
Gymnasium imagination env, and baseline adapters.
|
|
131
|
+
|
|
132
|
+
## Not to be confused with
|
|
133
|
+
|
|
134
|
+
[`phenoforge`](https://pypi.org/project/phenoforge/), the sibling engine, which fits and
|
|
135
|
+
ensembles **closed-form phenomenological equations**. `latentplant` **learns latent dynamics from
|
|
136
|
+
data**. Same industry, different object.
|
|
137
|
+
|
|
138
|
+
## References
|
|
139
|
+
|
|
140
|
+
The design follows: Chua et al. 2018, *Deep RL in a Handful of Trials with Probabilistic Dynamics
|
|
141
|
+
Models*, [arXiv:1805.12114](https://arxiv.org/abs/1805.12114) (probabilistic ensembles, TS-inf);
|
|
142
|
+
Lakshminarayanan et al. 2017, *Simple and Scalable Predictive Uncertainty Estimation using Deep
|
|
143
|
+
Ensembles*, [arXiv:1612.01474](https://arxiv.org/abs/1612.01474); Hafner et al. 2019, *Learning
|
|
144
|
+
Latent Dynamics for Planning from Pixels*, [arXiv:1811.04551](https://arxiv.org/abs/1811.04551)
|
|
145
|
+
(RSSM); Hafner et al. 2023, *Mastering Diverse Domains through World Models*,
|
|
146
|
+
[arXiv:2301.04104](https://arxiv.org/abs/2301.04104); Janner et al. 2019, *When to Trust Your
|
|
147
|
+
Model*, [arXiv:1906.08253](https://arxiv.org/abs/1906.08253) (rollout horizon and compounding
|
|
148
|
+
error); Che et al. 2016, *Recurrent Neural Networks for Multivariate Time Series with Missing
|
|
149
|
+
Values*, [arXiv:1606.01865](https://arxiv.org/abs/1606.01865) (cadence and masking); Romano et
|
|
150
|
+
al. 2019, *Conformalized Quantile Regression*,
|
|
151
|
+
[arXiv:1905.03222](https://arxiv.org/abs/1905.03222) (interval calibration); Williams et al.
|
|
152
|
+
2017, *Information Theoretic Model Predictive Control*,
|
|
153
|
+
[arXiv:1707.02342](https://arxiv.org/abs/1707.02342) (MPPI); Levine et al. 2020, *Offline
|
|
154
|
+
Reinforcement Learning: Tutorial, Review, and Perspectives*,
|
|
155
|
+
[arXiv:2005.01643](https://arxiv.org/abs/2005.01643) (why the planner refuses to issue
|
|
156
|
+
setpoints).
|
|
157
|
+
|
|
158
|
+
Developed by Felipe Santibanez-Leal. MIT licensed.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "latentplant"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Learned world models for industrial process time series: action-conditioned latent dynamics, imagination rollouts with calibrated uncertainty, planning and regime-change detection"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Felipe Santibanez-Leal", email = "fsantibanez@gmail.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["numpy>=1.24", "torch>=2.2"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
]
|
|
21
|
+
keywords = [
|
|
22
|
+
"world-models", "latent-dynamics", "model-based-rl", "imagination",
|
|
23
|
+
"process-control", "time-series", "uncertainty-quantification",
|
|
24
|
+
"mineral-processing", "rssm", "planning",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/fsantibanezleal/CAOS_LatentPlant"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8", "ruff>=0.4"]
|
|
32
|
+
onnx = ["onnx>=1.16", "onnxruntime>=1.18"]
|
|
33
|
+
baselines = ["sysidentpy>=0.3", "pysindy>=1.7"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
markers = ["slow: long-running training comparisons (deselect with -m 'not slow')"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py310"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""latentplant: learned world models for industrial process time series.
|
|
2
|
+
|
|
3
|
+
An action-conditioned model of process dynamics that can be rolled forward in
|
|
4
|
+
imagination, carries calibrated uncertainty, and supports what-if queries,
|
|
5
|
+
planning and regime-change detection.
|
|
6
|
+
|
|
7
|
+
This package is the engine. It is deliberately domain-agnostic: it knows about
|
|
8
|
+
observations, actions, exogenous inputs and cadence, not about flotation. The
|
|
9
|
+
mining instantiation lives in the product that consumes it (Porvenir).
|
|
10
|
+
|
|
11
|
+
Sibling engine, different concept: `phenoforge` fits and ensembles CLOSED-FORM
|
|
12
|
+
phenomenological equations. `latentplant` learns latent dynamics from data. They
|
|
13
|
+
share a domain and nothing else.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .calibrate import (
|
|
17
|
+
ConformalIntervals,
|
|
18
|
+
calibration_report,
|
|
19
|
+
coverage_per_horizon,
|
|
20
|
+
fit_conformal,
|
|
21
|
+
)
|
|
22
|
+
from .data.contract import PlantSchema, TagSpec, validate
|
|
23
|
+
from .data.windows import (
|
|
24
|
+
Episode,
|
|
25
|
+
Standardizer,
|
|
26
|
+
WindowSet,
|
|
27
|
+
chronological_split,
|
|
28
|
+
episodes_from_blocks,
|
|
29
|
+
make_windows,
|
|
30
|
+
split_on_gaps,
|
|
31
|
+
)
|
|
32
|
+
from .export import export_rssm, onnx_rollout
|
|
33
|
+
from .models.base import Rollout, WorldModel
|
|
34
|
+
from .models.ensemble import ProbabilisticEnsemble
|
|
35
|
+
from .models.rssm import RSSM
|
|
36
|
+
from .models.rssm_ensemble import RSSMEnsemble
|
|
37
|
+
from .planning import Plan, Planner, cvar_cost, imagination_gap, target_tracking_cost
|
|
38
|
+
|
|
39
|
+
__version__ = "0.04.000"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"ConformalIntervals",
|
|
43
|
+
"Episode",
|
|
44
|
+
"Plan",
|
|
45
|
+
"Planner",
|
|
46
|
+
"PlantSchema",
|
|
47
|
+
"ProbabilisticEnsemble",
|
|
48
|
+
"RSSM",
|
|
49
|
+
"RSSMEnsemble",
|
|
50
|
+
"Rollout",
|
|
51
|
+
"Standardizer",
|
|
52
|
+
"TagSpec",
|
|
53
|
+
"WindowSet",
|
|
54
|
+
"WorldModel",
|
|
55
|
+
"__version__",
|
|
56
|
+
"calibration_report",
|
|
57
|
+
"chronological_split",
|
|
58
|
+
"coverage_per_horizon",
|
|
59
|
+
"cvar_cost",
|
|
60
|
+
"episodes_from_blocks",
|
|
61
|
+
"export_rssm",
|
|
62
|
+
"fit_conformal",
|
|
63
|
+
"imagination_gap",
|
|
64
|
+
"make_windows",
|
|
65
|
+
"onnx_rollout",
|
|
66
|
+
"split_on_gaps",
|
|
67
|
+
"target_tracking_cost",
|
|
68
|
+
"validate",
|
|
69
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Split-conformal calibration of rollout intervals.
|
|
2
|
+
|
|
3
|
+
This module exists because of a measured failure, not because calibration is a
|
|
4
|
+
nice property to have. On the thickener PDE case the RSSM produced by far the
|
|
5
|
+
best POINT accuracy of any rung (37.5 percent better than persistence where the
|
|
6
|
+
others managed about 5) and simultaneously the worst calibration: its 90 percent
|
|
7
|
+
intervals covered 67.2 percent of outcomes. A model that is right about the
|
|
8
|
+
trajectory and wrong about its own confidence is dangerous in exactly the way a
|
|
9
|
+
process engineer cannot afford, because the interval is what decides whether the
|
|
10
|
+
advice gets acted on.
|
|
11
|
+
|
|
12
|
+
The method is conformalized quantile regression (Romano, Patterson and Candes,
|
|
13
|
+
"Conformalized Quantile Regression", arXiv:1905.03222), applied per horizon step
|
|
14
|
+
and per channel. The model's own predictive interval is taken as the starting
|
|
15
|
+
point and widened by a constant learned on a held-out calibration split:
|
|
16
|
+
|
|
17
|
+
E_i = max(lo_i - y_i, y_i - hi_i) nonconformity, signed to be
|
|
18
|
+
negative inside the interval
|
|
19
|
+
q = ceil((n+1)(1-alpha)) / n empirical quantile of E
|
|
20
|
+
[lo - q, hi + q] the calibrated interval
|
|
21
|
+
|
|
22
|
+
Per horizon step matters: compounding error means step 24 needs a wider
|
|
23
|
+
correction than step 1, and a single scalar would over-widen the near horizon to
|
|
24
|
+
fix the far one. Per channel matters for the same reason across units.
|
|
25
|
+
|
|
26
|
+
THE HONEST CAVEAT, which is stated here and must be repeated wherever these
|
|
27
|
+
intervals are shown: split conformal prediction guarantees marginal coverage
|
|
28
|
+
under EXCHANGEABILITY, and time series windows are not exchangeable. Overlapping
|
|
29
|
+
windows share rows, and process regimes drift, so the finite-sample guarantee
|
|
30
|
+
does not transfer. What this module actually delivers is a principled,
|
|
31
|
+
distribution-free WIDENING RULE fitted on data the model never trained on. Its
|
|
32
|
+
coverage is therefore something to MEASURE on a separate test split, never to
|
|
33
|
+
assert from the theorem. `calibration_report` measures exactly that, and it
|
|
34
|
+
reports the uncalibrated coverage next to the calibrated one so the correction
|
|
35
|
+
can be seen rather than trusted.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
from .models.base import Rollout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ConformalIntervals:
|
|
49
|
+
"""A fitted per-horizon, per-channel widening rule.
|
|
50
|
+
|
|
51
|
+
width : (horizon, n_obs) additive correction applied to both interval ends
|
|
52
|
+
level : the nominal coverage this was fitted for
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
width: np.ndarray
|
|
56
|
+
level: float
|
|
57
|
+
n_calibration: int
|
|
58
|
+
|
|
59
|
+
def apply(self, roll: Rollout) -> tuple[np.ndarray, np.ndarray]:
|
|
60
|
+
"""Correct a rollout's own interval into the calibrated one.
|
|
61
|
+
|
|
62
|
+
The correction can be NEGATIVE, which tightens rather than widens. That
|
|
63
|
+
is deliberate: a model whose intervals are too wide should have them
|
|
64
|
+
narrowed, and a rule that could only widen would be a one-way ratchet
|
|
65
|
+
that eventually makes every interval useless. The only thing enforced is
|
|
66
|
+
that the interval does not invert.
|
|
67
|
+
"""
|
|
68
|
+
lo, hi = roll.interval(self.level)
|
|
69
|
+
if lo.shape[1:] != self.width.shape:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"calibration was fitted for horizon/channels {self.width.shape} "
|
|
72
|
+
f"but this rollout is {lo.shape[1:]}"
|
|
73
|
+
)
|
|
74
|
+
new_lo, new_hi = lo - self.width, hi + self.width
|
|
75
|
+
mid = (lo + hi) / 2.0
|
|
76
|
+
inverted = new_lo > new_hi
|
|
77
|
+
return np.where(inverted, mid, new_lo), np.where(inverted, mid, new_hi)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fit_conformal(
|
|
81
|
+
roll: Rollout,
|
|
82
|
+
obs_fut: np.ndarray,
|
|
83
|
+
*,
|
|
84
|
+
level: float = 0.9,
|
|
85
|
+
mask: np.ndarray | None = None,
|
|
86
|
+
) -> ConformalIntervals:
|
|
87
|
+
"""Fit the widening rule on a CALIBRATION split the model never trained on.
|
|
88
|
+
|
|
89
|
+
`mask` is the multi-rate freshness mask: on a case where the target is an
|
|
90
|
+
hourly assay repeated across minute rows, calibrating against the repeats
|
|
91
|
+
would measure how well the model copies a held value, which is not the
|
|
92
|
+
quantity anyone wants a confidence interval on. Stale entries are excluded
|
|
93
|
+
from the quantile.
|
|
94
|
+
"""
|
|
95
|
+
y = np.asarray(obs_fut, dtype=float)
|
|
96
|
+
lo, hi = roll.interval(level)
|
|
97
|
+
scores = np.maximum(lo - y, y - hi) # (n, horizon, n_obs)
|
|
98
|
+
|
|
99
|
+
horizon, n_obs = y.shape[1], y.shape[2]
|
|
100
|
+
width = np.zeros((horizon, n_obs))
|
|
101
|
+
kept = 0
|
|
102
|
+
for h in range(horizon):
|
|
103
|
+
for c in range(n_obs):
|
|
104
|
+
s = scores[:, h, c]
|
|
105
|
+
if mask is not None:
|
|
106
|
+
s = s[np.asarray(mask, dtype=bool)[:, h, c]]
|
|
107
|
+
s = s[np.isfinite(s)]
|
|
108
|
+
if s.size == 0:
|
|
109
|
+
# nothing fresh at this horizon and channel: widening by zero is
|
|
110
|
+
# the only honest choice, and the empty count is reported so the
|
|
111
|
+
# caller can see the rule was never actually fitted here
|
|
112
|
+
continue
|
|
113
|
+
kept = max(kept, int(s.size))
|
|
114
|
+
# the finite-sample conformal quantile, which is slightly above the
|
|
115
|
+
# plain empirical one and is what makes the rule conservative rather
|
|
116
|
+
# than exactly-nominal on the calibration set itself
|
|
117
|
+
rank = min(1.0, np.ceil((s.size + 1) * level) / s.size)
|
|
118
|
+
# NOT clamped at zero: a negative quantile means the model's own
|
|
119
|
+
# interval was already wider than it needed to be, and tightening it
|
|
120
|
+
# is the correct response. Clamping would make this a one-way
|
|
121
|
+
# widener and would over-cover on every well-calibrated rung.
|
|
122
|
+
width[h, c] = float(np.quantile(s, rank))
|
|
123
|
+
|
|
124
|
+
return ConformalIntervals(width=width, level=level, n_calibration=kept)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def coverage_per_horizon(
|
|
128
|
+
lo: np.ndarray, hi: np.ndarray, obs_fut: np.ndarray, mask: np.ndarray | None = None
|
|
129
|
+
) -> np.ndarray:
|
|
130
|
+
"""Empirical coverage at each horizon step, averaged over windows and channels."""
|
|
131
|
+
y = np.asarray(obs_fut, dtype=float)
|
|
132
|
+
inside = (y >= lo) & (y <= hi)
|
|
133
|
+
if mask is None:
|
|
134
|
+
return inside.mean(axis=(0, 2))
|
|
135
|
+
m = np.asarray(mask, dtype=bool)
|
|
136
|
+
num = (inside & m).sum(axis=(0, 2))
|
|
137
|
+
den = np.maximum(m.sum(axis=(0, 2)), 1)
|
|
138
|
+
return num / den
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def mean_interval_width(lo: np.ndarray, hi: np.ndarray) -> np.ndarray:
|
|
142
|
+
"""Average interval width at each horizon step.
|
|
143
|
+
|
|
144
|
+
Reported alongside coverage because widening always buys coverage: an
|
|
145
|
+
infinitely wide interval covers everything and says nothing. The pair
|
|
146
|
+
(coverage, width) is the only honest way to show a calibration worked.
|
|
147
|
+
"""
|
|
148
|
+
return (hi - lo).mean(axis=(0, 2))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def calibration_report(
|
|
152
|
+
conformal: ConformalIntervals,
|
|
153
|
+
roll: Rollout,
|
|
154
|
+
obs_fut: np.ndarray,
|
|
155
|
+
*,
|
|
156
|
+
mask: np.ndarray | None = None,
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""Measure calibrated versus uncalibrated coverage on a TEST split.
|
|
159
|
+
|
|
160
|
+
Both are reported. The uncalibrated number is what the model believed and
|
|
161
|
+
the calibrated number is what the correction achieved, and showing only the
|
|
162
|
+
second would hide the size of the problem being corrected.
|
|
163
|
+
"""
|
|
164
|
+
raw_lo, raw_hi = roll.interval(conformal.level)
|
|
165
|
+
cal_lo, cal_hi = conformal.apply(roll)
|
|
166
|
+
|
|
167
|
+
raw_cov = coverage_per_horizon(raw_lo, raw_hi, obs_fut, mask)
|
|
168
|
+
cal_cov = coverage_per_horizon(cal_lo, cal_hi, obs_fut, mask)
|
|
169
|
+
return {
|
|
170
|
+
"level": conformal.level,
|
|
171
|
+
"n_calibration": conformal.n_calibration,
|
|
172
|
+
"raw_coverage_per_horizon": raw_cov.tolist(),
|
|
173
|
+
"calibrated_coverage_per_horizon": cal_cov.tolist(),
|
|
174
|
+
"raw_coverage": float(raw_cov.mean()),
|
|
175
|
+
"calibrated_coverage": float(cal_cov.mean()),
|
|
176
|
+
"raw_width_per_horizon": mean_interval_width(raw_lo, raw_hi).tolist(),
|
|
177
|
+
"calibrated_width_per_horizon": mean_interval_width(cal_lo, cal_hi).tolist(),
|
|
178
|
+
"width_inflation": float(
|
|
179
|
+
mean_interval_width(cal_lo, cal_hi).mean()
|
|
180
|
+
/ max(mean_interval_width(raw_lo, raw_hi).mean(), 1e-12)
|
|
181
|
+
),
|
|
182
|
+
"caveat": (
|
|
183
|
+
"split conformal guarantees marginal coverage under exchangeability, "
|
|
184
|
+
"which time-series windows violate; these coverages are MEASURED on a "
|
|
185
|
+
"held-out split, not implied by the theorem"
|
|
186
|
+
),
|
|
187
|
+
}
|