Skip to content

Commit fd1ccc2

Browse files
committed
feat(inference): add synthetic science recipe workflow and output writer
1 parent b5d2a3c commit fd1ccc2

6 files changed

Lines changed: 344 additions & 0 deletions

File tree

docs/inference_workflows.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,22 @@ For large particle counts, configure optional IFU accumulation controls:
304304
305305
These settings are used by the particlewise IFU builders in ``rubix.core.ifu``.
306306
307+
308+
Synthetic Science Recipe
309+
------------------------
310+
311+
Run an end-to-end synthetic workflow (optimize -> VI -> posterior predictive ->
312+
residual metrics) and persist science-ready outputs:
313+
314+
.. code-block:: bash
315+
316+
python scripts/run_synthetic_science_recipe.py \
317+
--output-dir outputs/science_recipe \
318+
--nx 8 --ny 8 --nw 64 \
319+
--optimize-steps 200 \
320+
--vi-steps 200 \
321+
--num-posterior-draws 16
322+
307323
Benchmarking Full-IFU Optimization
308324
----------------------------------
309325

docs/rubix.inference.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ rubix.inference.vi_benchmark module
116116
:undoc-members:
117117
:show-inheritance:
118118

119+
rubix.inference.workflows module
120+
--------------------------------
121+
122+
.. automodule:: rubix.inference.workflows
123+
:members:
124+
:undoc-members:
125+
:show-inheritance:
126+
119127
Module contents
120128
---------------
121129

rubix/inference/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
benchmark_variational_inference,
6464
vi_benchmark_result_to_dict,
6565
)
66+
from .workflows import run_synthetic_science_recipe, save_science_recipe_outputs
6667

6768
__all__ = [
6869
"IdentityTransform",
@@ -122,4 +123,6 @@
122123
"summarize_predictive_cube_samples",
123124
"save_checkpoint",
124125
"value_and_grad",
126+
"run_synthetic_science_recipe",
127+
"save_science_recipe_outputs",
125128
]

rubix/inference/workflows.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any
6+
7+
import jax.numpy as jnp
8+
import numpy as np
9+
10+
from rubix.core.data import Galaxy, GasData, RubixData, StarsData
11+
12+
from .optimize import optimize_ifu_cube
13+
from .posterior_predictive import (
14+
compute_residual_products,
15+
sample_posterior_predictive_cubes,
16+
summarize_masked_metrics,
17+
summarize_predictive_cube_samples,
18+
)
19+
from .variational import optimize_variational_ifu_cube
20+
21+
22+
class SyntheticScalePipeline:
23+
"""Simple synthetic IFU pipeline used by the science recipe workflow."""
24+
25+
def __init__(self, template: jnp.ndarray):
26+
self.template = template
27+
28+
def run_sharded(self, rubixdata: RubixData) -> jnp.ndarray:
29+
scale = rubixdata.stars.age[0]
30+
return scale * self.template
31+
32+
33+
def _make_static_data() -> RubixData:
34+
return RubixData(
35+
galaxy=Galaxy(),
36+
stars=StarsData(
37+
coords=jnp.zeros((1, 3)),
38+
velocity=jnp.zeros((1, 3)),
39+
mass=jnp.ones(1),
40+
age=jnp.array([0.0]),
41+
metallicity=jnp.array([0.01]),
42+
),
43+
gas=GasData(
44+
coords=jnp.zeros((1, 3)),
45+
velocity=jnp.zeros((1, 3)),
46+
mass=jnp.ones(1),
47+
),
48+
)
49+
50+
51+
def _to_jsonable(value: Any) -> Any:
52+
"""Convert nested JAX/NumPy containers into JSON-serializable values."""
53+
if isinstance(value, dict):
54+
return {k: _to_jsonable(v) for k, v in value.items()}
55+
if isinstance(value, (list, tuple)):
56+
return [_to_jsonable(v) for v in value]
57+
if isinstance(value, np.ndarray):
58+
if value.ndim == 0:
59+
return value.item()
60+
return value.tolist()
61+
if isinstance(value, jnp.ndarray):
62+
arr = np.asarray(value)
63+
if arr.ndim == 0:
64+
return arr.item()
65+
return arr.tolist()
66+
if isinstance(value, np.generic):
67+
return value.item()
68+
return value
69+
70+
71+
def run_synthetic_science_recipe(
72+
cube_shape: tuple[int, int, int] = (4, 4, 16),
73+
target_scale: float = 1.7,
74+
optimize_steps: int = 120,
75+
vi_steps: int = 120,
76+
num_vi_samples: int = 4,
77+
num_posterior_draws: int = 8,
78+
seed: int = 0,
79+
) -> dict[str, Any]:
80+
"""Run a compact end-to-end synthetic science workflow.
81+
82+
This workflow performs deterministic optimization, variational inference,
83+
posterior predictive sampling, and residual/metric summarization.
84+
85+
Args:
86+
cube_shape (tuple[int, int, int], optional): Synthetic IFU cube shape
87+
``(nx, ny, nw)``. Defaults to ``(4, 4, 16)``.
88+
target_scale (float, optional): Multiplicative scale for the synthetic
89+
target cube. Defaults to 1.7.
90+
optimize_steps (int, optional): Maximum deterministic optimization
91+
steps. Defaults to 120.
92+
vi_steps (int, optional): Maximum variational optimization steps.
93+
Defaults to 120.
94+
num_vi_samples (int, optional): Monte Carlo samples per VI step.
95+
Defaults to 4.
96+
num_posterior_draws (int, optional): Number of posterior predictive
97+
cube draws. Defaults to 8.
98+
seed (int, optional): Random seed for VI and predictive sampling.
99+
Defaults to 0.
100+
101+
Returns:
102+
dict[str, Any]: Workflow outputs and diagnostic summaries.
103+
"""
104+
template = jnp.ones(cube_shape, dtype=jnp.float32)
105+
target = target_scale * template
106+
107+
pipeline = SyntheticScalePipeline(template)
108+
static_data = _make_static_data()
109+
params_init = {"stars": {"age": jnp.array([0.2])}}
110+
111+
opt_result = optimize_ifu_cube(
112+
pipeline=pipeline,
113+
params_init=params_init,
114+
static_data=static_data,
115+
target=target,
116+
learning_rate=0.1,
117+
max_steps=optimize_steps,
118+
tol=1e-8,
119+
)
120+
121+
vi_result = optimize_variational_ifu_cube(
122+
pipeline=pipeline,
123+
params_init=params_init,
124+
static_data=static_data,
125+
target=target,
126+
sigma=jnp.ones_like(target),
127+
learning_rate=5e-2,
128+
max_steps=vi_steps,
129+
tol=1e-8,
130+
num_samples=num_vi_samples,
131+
beta_kl=1e-4,
132+
seed=seed,
133+
)
134+
135+
predictive_samples = sample_posterior_predictive_cubes(
136+
pipeline=pipeline,
137+
posterior_mean_params=vi_result.posterior_mean_params,
138+
posterior_log_std_params=vi_result.posterior_log_std_params,
139+
static_data=static_data,
140+
num_samples=num_posterior_draws,
141+
seed=seed + 1,
142+
)
143+
predictive_summary = summarize_predictive_cube_samples(predictive_samples)
144+
residual_products = compute_residual_products(
145+
prediction=predictive_summary["mean"],
146+
target=target,
147+
)
148+
metrics = summarize_masked_metrics(
149+
prediction=predictive_summary["mean"],
150+
target=target,
151+
)
152+
153+
return {
154+
"config": {
155+
"cube_shape": cube_shape,
156+
"target_scale": target_scale,
157+
"optimize_steps": optimize_steps,
158+
"vi_steps": vi_steps,
159+
"num_vi_samples": num_vi_samples,
160+
"num_posterior_draws": num_posterior_draws,
161+
"seed": seed,
162+
},
163+
"optimization": {
164+
"final_loss": opt_result.final_loss,
165+
"best_loss": opt_result.best_loss,
166+
"steps_run": opt_result.steps_run,
167+
"converged": opt_result.converged,
168+
},
169+
"variational": {
170+
"final_objective": vi_result.final_objective,
171+
"best_objective": vi_result.best_objective,
172+
"steps_run": vi_result.steps_run,
173+
"converged": vi_result.converged,
174+
},
175+
"predictive_summary": predictive_summary,
176+
"residual_products": residual_products,
177+
"metrics": metrics,
178+
}
179+
180+
181+
def save_science_recipe_outputs(
182+
outputs: dict[str, Any],
183+
output_dir: str,
184+
) -> None:
185+
"""Persist workflow outputs to JSON and NPZ files.
186+
187+
Args:
188+
outputs (dict[str, Any]): Outputs from
189+
:func:`run_synthetic_science_recipe`.
190+
output_dir (str): Destination directory.
191+
"""
192+
out_dir = Path(output_dir)
193+
out_dir.mkdir(parents=True, exist_ok=True)
194+
195+
summary = {
196+
"config": outputs["config"],
197+
"optimization": outputs["optimization"],
198+
"variational": outputs["variational"],
199+
"metrics": outputs["metrics"],
200+
}
201+
json_summary = _to_jsonable(summary)
202+
(out_dir / "summary.json").write_text(
203+
json.dumps(json_summary, indent=2), encoding="utf-8"
204+
)
205+
206+
predictive_np = {k: np.asarray(v) for k, v in outputs["predictive_summary"].items()}
207+
residual_np = {k: np.asarray(v) for k, v in outputs["residual_products"].items()}
208+
209+
np.savez(out_dir / "predictive_summary.npz", **predictive_np)
210+
np.savez(out_dir / "residual_products.npz", **residual_np)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python
2+
import argparse
3+
4+
from rubix.inference.workflows import (
5+
run_synthetic_science_recipe,
6+
save_science_recipe_outputs,
7+
)
8+
9+
10+
def parse_args() -> argparse.Namespace:
11+
parser = argparse.ArgumentParser(
12+
description="Run synthetic end-to-end science workflow and save outputs."
13+
)
14+
parser.add_argument("--output-dir", type=str, default="outputs/science_recipe")
15+
parser.add_argument("--nx", type=int, default=4)
16+
parser.add_argument("--ny", type=int, default=4)
17+
parser.add_argument("--nw", type=int, default=16)
18+
parser.add_argument("--target-scale", type=float, default=1.7)
19+
parser.add_argument("--optimize-steps", type=int, default=120)
20+
parser.add_argument("--vi-steps", type=int, default=120)
21+
parser.add_argument("--num-vi-samples", type=int, default=4)
22+
parser.add_argument("--num-posterior-draws", type=int, default=8)
23+
parser.add_argument("--seed", type=int, default=0)
24+
return parser.parse_args()
25+
26+
27+
def main() -> None:
28+
args = parse_args()
29+
outputs = run_synthetic_science_recipe(
30+
cube_shape=(args.nx, args.ny, args.nw),
31+
target_scale=args.target_scale,
32+
optimize_steps=args.optimize_steps,
33+
vi_steps=args.vi_steps,
34+
num_vi_samples=args.num_vi_samples,
35+
num_posterior_draws=args.num_posterior_draws,
36+
seed=args.seed,
37+
)
38+
save_science_recipe_outputs(outputs, args.output_dir)
39+
40+
41+
if __name__ == "__main__":
42+
main()

tests/test_inference_workflows.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import json
2+
3+
import numpy as np
4+
5+
from rubix.inference.workflows import (
6+
run_synthetic_science_recipe,
7+
save_science_recipe_outputs,
8+
)
9+
10+
11+
def test_run_synthetic_science_recipe_returns_expected_structure():
12+
outputs = run_synthetic_science_recipe(
13+
cube_shape=(2, 2, 4),
14+
target_scale=1.5,
15+
optimize_steps=20,
16+
vi_steps=20,
17+
num_vi_samples=2,
18+
num_posterior_draws=4,
19+
seed=0,
20+
)
21+
22+
assert "config" in outputs
23+
assert "optimization" in outputs
24+
assert "variational" in outputs
25+
assert "predictive_summary" in outputs
26+
assert "residual_products" in outputs
27+
assert "metrics" in outputs
28+
29+
assert outputs["predictive_summary"]["mean"].shape == (2, 2, 4)
30+
assert outputs["residual_products"]["residual"].shape == (2, 2, 4)
31+
assert outputs["metrics"]["mse"] >= 0.0
32+
assert outputs["metrics"]["mae"] >= 0.0
33+
34+
35+
def test_save_science_recipe_outputs_writes_json_and_npz(tmp_path):
36+
outputs = run_synthetic_science_recipe(
37+
cube_shape=(2, 2, 4),
38+
target_scale=1.2,
39+
optimize_steps=10,
40+
vi_steps=10,
41+
num_vi_samples=2,
42+
num_posterior_draws=3,
43+
seed=1,
44+
)
45+
46+
save_science_recipe_outputs(outputs, str(tmp_path))
47+
48+
summary_path = tmp_path / "summary.json"
49+
predictive_path = tmp_path / "predictive_summary.npz"
50+
residual_path = tmp_path / "residual_products.npz"
51+
52+
assert summary_path.exists()
53+
assert predictive_path.exists()
54+
assert residual_path.exists()
55+
56+
summary = json.loads(summary_path.read_text(encoding="utf-8"))
57+
assert summary["config"]["cube_shape"] == [2, 2, 4]
58+
assert "final_loss" in summary["optimization"]
59+
assert "final_objective" in summary["variational"]
60+
61+
predictive = np.load(predictive_path)
62+
residual = np.load(residual_path)
63+
64+
assert predictive["mean"].shape == (2, 2, 4)
65+
assert residual["residual"].shape == (2, 2, 4)

0 commit comments

Comments
 (0)