Skip to content

Add self-contained implementation of the Simformer approach - #36

Open
tHarvey303 wants to merge 2 commits into
mainfrom
feature/torch-simformer
Open

Add self-contained implementation of the Simformer approach#36
tHarvey303 wants to merge 2 commits into
mainfrom
feature/torch-simformer

Conversation

@tHarvey303

@tHarvey303 tHarvey303 commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Written in torch rather than jax, this reimplements the Simformer approach to learn full conditional distributions.

Summary by CodeRabbit

  • New Features

    • Added native PyTorch Simformer training and inference.
    • Added posterior, likelihood, joint, masked, and interval-constrained sampling.
    • Added log-probability evaluation and reliable model save/load support.
    • Added a two-moons demonstration covering training, sampling, guided inference, and diagnostics.
  • Documentation

    • Updated Simformer documentation and examples for the new workflow.
    • Added project development and usage guidance.
  • Chores

    • Removed JAX from the runtime dependency and installation documentation.

tHarvey303 and others added 2 commits July 5, 2026 00:27
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iants

Adds src/synference/simformer/, a self-contained PyTorch port of the original
JAX Simformer (Gloeckler et al. 2024, scoresbibm/probjax), and rewires
Simformer_Fitter to use it. Replaces (a) the scoresbibm/JAX wrapper
(src/synference/simformer.py, deleted) and (b) SBI_Fitter.run_single_simformer,
which depended on the unmerged sbi PR #1621 (deleted). jax is removed from the
dependencies.

The new subpackage faithfully replicates the paper architecture and math:
per-variable tokens with frozen node-id embeddings and a mask-gated condition
token, pre-LN transformer with time context injected in every MLP block,
VE-SDE (default) and VP-SDE, masked denoising score matching with
structured-random condition-mask sampling, adaptive gradient clipping, and
early stopping. Inference supports arbitrary condition masks (posterior,
likelihood, joint, missing bands), base attention masks, interval-constrained
sampling via Tweedie guidance, and log probabilities via the probability-flow
ODE. Models serialize to a plain torch payload and reload in a fresh process
(SimformerModel.save/load).

API notes (clean break):
- sample_posterior(condition_mask=...) replaces the misnamed attention_mask
  argument (it was always a condition mask); full sentinel unchanged.
- log_prob(X_test, theta, ...) takes theta as the second positional argument,
  fixing evaluate_model silently passing y_test as a mask.
- Config overrides are validated against known keys instead of being silently
  swallowed.
- Old JAX-era model pickles cannot be loaded by the new implementation.
- fit_catalogue's Simformer branch now transposes samples like the regular
  branch, fixing wrong quantiles for multi-row catalogues.

Validation: analytic 2D-Gaussian conditional gate, TwoMoons (bimodal posterior,
TARP calibration, likelihood/joint conditionals, interval guidance, log-prob),
and the galaxy test library end-to-end (training, artifacts, reload in a fresh
process, fit_catalogue) in tests/test_simformer.py; full suite passes. A demo
lives in examples/simformer/scripts/two_moons_demo.py and the docs notebook
was rewritten for the new API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces the legacy JAX Simformer integration with a native PyTorch implementation, including transformer and diffusion components, training, conditional sampling, interval guidance, log probabilities, serialization, SBI integration, examples, documentation, dependency updates, and comprehensive tests.

Changes

Native Simformer foundations

Layer / File(s) Summary
Model foundations and public API
src/synference/simformer/*, src/synference/__init__.py, tests/test_simformer.py
Adds transformer components, SDE implementations, condition and attention masks, package exports, and architecture/SDE/mask validation tests.
Training and inference runtime
src/synference/simformer/*, tests/test_simformer.py
Adds training, posterior and likelihood sampling, interval guidance, probability-flow log probabilities, model serialization, uniform-box priors, and runtime behavior tests.
SBI fitter migration and persistence
src/synference/sbi_runner.py, examples/sbi/scripts/train_model.py, examples/simformer/scripts/fit_simformer_model.py, tests/test_simformer.py
Replaces the JAX Simformer path with native training and loading, updates diagnostics and catalogue fitting, changes condition-mask handling, and validates fitter artifacts and reload behavior.
Examples, documentation, and dependency updates
CLAUDE.md, README.md, pyproject.toml, docs/source/..., examples/simformer/scripts/two_moons_demo.py, src/synference/__init__.py
Documents the native workflow, removes JAX and the Simformer optional dependency group, adds a two-moons demonstration, and exposes the native model and training entry points.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TrainingData
  participant train_simformer
  participant SimformerModel
  participant Simformer_Fitter
  participant Sampling
  TrainingData->>train_simformer: theta and x training arrays
  train_simformer->>SimformerModel: trained score network and SDE
  Simformer_Fitter->>SimformerModel: save or load native model
  Simformer_Fitter->>Sampling: condition mask and observations
  Sampling->>Simformer_Fitter: posterior, interval, or log-probability results
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a self-contained Simformer implementation replacing the prior approach.
Docstring Coverage ✅ Passed Docstring coverage is 91.89% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/torch-simformer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
src/synference/sbi_runner.py (1)

8397-8402: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer an exception over assert for this input contract.

sample_posterior is a public entry point and the column/mask check is user-facing validation; assert is removed under python -O, after which a mismatch surfaces deeper inside SimformerModel._prepare_x_o with a less obvious message.

♻️ Proposed change
-        assert X_test.shape[1] == mask.sum(), (
-            f"X_test has {X_test.shape[1]} columns but the condition mask expects "
-            f"{int(mask.sum())} observed values."
-        )
+        if X_test.shape[1] != int(mask.sum()):
+            raise ValueError(
+                f"X_test has {X_test.shape[1]} columns but the condition mask expects "
+                f"{int(mask.sum())} observed values."
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/sbi_runner.py` around lines 8397 - 8402, In sample_posterior,
replace the assert guarding the X_test column count against mask.sum() with
explicit user-facing validation that raises an appropriate exception containing
the existing mismatch details. Preserve the current shape normalization and
allow valid column/mask matches to proceed unchanged.
tests/test_simformer.py (1)

405-427: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider marking the training fixtures as slow.

Both module-scoped fixtures train real models (3000 steps on 10k samples, plus 600 steps on the galaxy library) and downstream tests draw thousands of samples with 100 integration steps. That makes pytest on the default selection substantially slower. A @pytest.mark.slow on these classes (with a CI opt-in) would keep the fast suite fast without losing coverage.

Also applies to: 533-561

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_simformer.py` around lines 405 - 427, Mark the model-training test
fixtures or their containing test classes, including the two-moons fixture
two_moons_model and the galaxy-library fixture covering the other referenced
range, with pytest.mark.slow so they are excluded from the default fast suite
while remaining available through CI’s slow-test opt-in.
src/synference/simformer/sampling.py (1)

179-203: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Exact divergence cost scales with the number of latent nodes.

One backward pass per latent dimension per ODE step (num_steps × n_latent graph traversals) makes divergence="exact" expensive for galaxy models with many parameters, and it is the default in SimformerModel.log_prob and Simformer_Fitter.log_prob. Consider defaulting to Hutchinson above a dimension threshold, or documenting the cost at the public entry points.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/simformer/sampling.py` around lines 179 - 203, Address the
scalability of exact divergence in _divergence_exact by either switching to
Hutchinson divergence above a clearly defined latent-dimension threshold or
documenting the num_steps × n_latent backward-pass cost at the public
SimformerModel.log_prob and Simformer_Fitter.log_prob entry points. Preserve
exact divergence behavior below any threshold and keep the existing
log-probability API consistent.
src/synference/simformer/model.py (1)

538-578: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Harden SimformerModel.load to avoid unpickling arbitrary objects.

weights_only=False keeps full pickle execution available even though the saved payload is just model weights/config and numpy arrays. Use weights_only=True with explicit safe globals for the numpy dtypes/arrays needed by the format, or switch the saved format to an unsafe-free serialization; passing map_location to torch.load is also clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/simformer/model.py` around lines 538 - 578, Harden
SimformerModel.load by replacing unsafe full-pickle loading with
weights_only=True and registering the NumPy dtype/array globals required by the
saved payload, while passing the method’s map_location argument directly to
torch.load. Preserve the existing format-version validation and reconstruction
flow.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/synference/simformer/masks.py`:
- Around line 57-69: Update random_condition_mask so the Beta probability is
derived from generator-controlled Gamma draws rather than beta_dist.sample(),
which uses the global RNG. Construct the Beta-equivalent ratio using alpha and
beta parameters, pass the supplied generator to the underlying draws, and
preserve the existing mask shape and _fix_all_true_rows behavior.

In `@src/synference/simformer/model.py`:
- Around line 152-160: The _as_condition_mask method currently returns CPU masks
that are later used to index device-local tensors. Move the validated mask to
self.device before returning it, and update sample_intervals so constraint_mask
and effective_condition conversions follow the same device placement; ensure
_init_x_T, _prepare_x_o, _extract_latents, and log_prob can index without
cross-device errors.

In `@src/synference/simformer/train.py`:
- Around line 374-394: Update the final checkpoint restoration condition around
best_state so the model loads the best validation checkpoint whenever best_state
is available, regardless of whether stats["early_stopped"] is true. Preserve the
existing guard that avoids loading when no validation checkpoint was recorded.
- Around line 277-281: Shuffle the input rows with a seeded permutation before
computing the validation split in train_simformer, so data_val is sampled
independently of caller ordering while preserving reproducibility. Apply the
same permuted tensor to both data_val and data_train, and retain the existing
validation repeat and empty-validation behavior.

---

Nitpick comments:
In `@src/synference/sbi_runner.py`:
- Around line 8397-8402: In sample_posterior, replace the assert guarding the
X_test column count against mask.sum() with explicit user-facing validation that
raises an appropriate exception containing the existing mismatch details.
Preserve the current shape normalization and allow valid column/mask matches to
proceed unchanged.

In `@src/synference/simformer/model.py`:
- Around line 538-578: Harden SimformerModel.load by replacing unsafe
full-pickle loading with weights_only=True and registering the NumPy dtype/array
globals required by the saved payload, while passing the method’s map_location
argument directly to torch.load. Preserve the existing format-version validation
and reconstruction flow.

In `@src/synference/simformer/sampling.py`:
- Around line 179-203: Address the scalability of exact divergence in
_divergence_exact by either switching to Hutchinson divergence above a clearly
defined latent-dimension threshold or documenting the num_steps × n_latent
backward-pass cost at the public SimformerModel.log_prob and
Simformer_Fitter.log_prob entry points. Preserve exact divergence behavior below
any threshold and keep the existing log-probability API consistent.

In `@tests/test_simformer.py`:
- Around line 405-427: Mark the model-training test fixtures or their containing
test classes, including the two-moons fixture two_moons_model and the
galaxy-library fixture covering the other referenced range, with
pytest.mark.slow so they are excluded from the default fast suite while
remaining available through CI’s slow-test opt-in.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 567b7286-faa7-4b66-8679-be3d1d2d15e8

📥 Commits

Reviewing files that changed from the base of the PR and between 0e24fd6 and 452ac97.

📒 Files selected for processing (18)
  • CLAUDE.md
  • README.md
  • docs/source/advanced_topics/simformer.ipynb
  • docs/source/getting_started/installation.rst
  • examples/sbi/scripts/train_model.py
  • examples/simformer/scripts/fit_simformer_model.py
  • examples/simformer/scripts/two_moons_demo.py
  • pyproject.toml
  • src/synference/__init__.py
  • src/synference/sbi_runner.py
  • src/synference/simformer/__init__.py
  • src/synference/simformer/masks.py
  • src/synference/simformer/model.py
  • src/synference/simformer/nn.py
  • src/synference/simformer/sampling.py
  • src/synference/simformer/sde.py
  • src/synference/simformer/train.py
  • tests/test_simformer.py
💤 Files with no reviewable changes (4)
  • pyproject.toml
  • README.md
  • docs/source/getting_started/installation.rst
  • examples/sbi/scripts/train_model.py

Comment on lines +57 to +69
def random_condition_mask(
num_samples: int,
theta_dim: int,
x_dim: int,
generator: Optional[torch.Generator] = None,
alpha: float = 1.0,
beta: float = 4.0,
) -> torch.Tensor:
"""Bernoulli masks with per-batch probability drawn from ``Beta(alpha, beta)``."""
beta_dist = torch.distributions.Beta(alpha, beta)
prob = beta_dist.sample()
mask = torch.rand(num_samples, theta_dim + x_dim, generator=generator) < prob
return _fix_all_true_rows(mask)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does torch.distributions.Beta.sample accept a generator argument?

💡 Result:

As of July 25, 2026, the torch.distributions.Beta.sample method does not natively accept a generator argument [1][2][3]. While some updates to the PyTorch torch.distributions library have been initiated to standardize generator support across various distributions [4][5], the sample and rsample methods for the Beta distribution currently maintain their standard signature, which typically accepts only sample_shape [1][2]. Users requiring control over the random number generation process for reproducibility generally rely on setting the global PyTorch manual seed (torch.manual_seed()) before calling the sample() method [6]. Recent development activity in the PyTorch repository (as of March 2026) indicates ongoing efforts to add a generator parameter to the base distribution classes [5], but users should verify the specific version of PyTorch they are using to determine if this has been implemented for the Beta class. To check your current environment's capabilities, you can inspect the signature of your distribution instance's sample method using Python's inspect module.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant files/interfaces without executing repository code.
printf 'Tracked python files matching paths:\n'
git ls-files | rg '(^|/)masks\.py$|(^|/)train_simformer\.py$|(^|/)synference\.py$' || true

printf '\nRelevant definitions/usages:\n'
rg -n "def random_condition_mask|random_condition_mask\(|torch\.manual_seed|random_generatr|generator|Beta\\(" -S .

printf '\nTarget masked section:\n'
sed -n '1,120p' "$(git ls-files | rg '/masks\.py$' | head -n 1)"

printf '\nTrain caller references:\n'
for f in $(git ls-files | rg 'train_simformer\.py$|simformer.*\.py$'); do
  if rg -q "random_condition_mask" "$f"; then
    echo "--- $f"
    rg -n -C 5 "random_condition_mask|torch\.manual_seed|random_generatr|Generator" "$f"
  fi
done

Repository: synthesizer-project/synference

Length of output: 24253


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the Simformer training path around the generator seed and mask call.
sed -n '235,330p' src/synference/simformer/train.py

printf '\nConfiguration/name references for random condition mask:\n'
rg -n --hidden -S '"random"|random_condition_mask|condition_mask.*random|condition_mask_fn|random"%' . || true

Repository: synthesizer-project/synference

Length of output: 7343


Generate the Beta probability with the passed RNG.

random_condition_mask re-seeds generator inside the call, but torch.distributions.Beta(...).sample() does not accept a generator argument, so its global RNG still determines prob. Use a seeded Beta proxy, e.g. Gamma draws, to keep this sampler fully reproducible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/simformer/masks.py` around lines 57 - 69, Update
random_condition_mask so the Beta probability is derived from
generator-controlled Gamma draws rather than beta_dist.sample(), which uses the
global RNG. Construct the Beta-equivalent ratio using alpha and beta parameters,
pass the supplied generator to the underlying draws, and preserve the existing
mask shape and _fix_all_true_rows behavior.

Comment on lines +152 to +160
def _as_condition_mask(self, condition_mask) -> torch.Tensor:
mask = torch.as_tensor(np.asarray(condition_mask), dtype=torch.bool)
if mask.shape != (self.num_nodes,):
raise ValueError(
f"condition_mask must have shape ({self.num_nodes},), got {tuple(mask.shape)}."
)
if mask.all():
raise ValueError("condition_mask cannot condition on every node.")
return mask

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether any caller/test exercises the Simformer model on a non-CPU device.
rg -nP --type=py -C3 '(to\(\s*["'\'']cuda|device\s*=\s*["'\'']cuda|SimformerModel[^\n]*\.to\()' src tests examples

Repository: synthesizer-project/synference

Length of output: 529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching model.py:"
fd -a 'model\.py$' . || true

echo
echo "Inspect src/synference/simformer/model.py around relevant symbols:"
wc -l src/synference/simformer/model.py
sed -n '130,310p' src/synference/simformer/model.py
sed -n '460,510p' src/synference/simformer/model.py
sed -n '380,415p' src/synference/simformer/model.py

Repository: synthesizer-project/synference

Length of output: 12743


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '400,525p' src/synference/simformer/model.py

Repository: synthesizer-project/synference

Length of output: 5773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if command -v python3 >/dev/null 2>&1; then
  python3 - <<'PY'
import subprocess, tempfile, os, textwrap
tmpdir = tempfile.mkdtemp()
src = os.path.join(tmpdir, "test_device_mask.py")
with open(src, "w") as f:
    f.write(textwrap.dedent('''
        import torch

        x = torch.arange(2 * 3.0).reshape(2, 3).cuda() if torch.cuda.is_available() else torch.arange(2 * 3.0).reshape(2, 3)
        cpu_mask = torch.tensor([True, False, True])
        try:
            result = x[:, cpu_mask]
            print("cpu_mask_indexing_succeeded")
        except Exception as e:
            print(type(e).__name__)
            print(str(e).splitlines()[0])

        if torch.cuda.is_available():
            gpu_mask = cpu_mask.cuda()
            result = x[:, gpu_mask]
            print("gpu_mask_indexing_succeeded")
    '''))
    subprocess.run([sys.executable, src], text=True)
PY
else
  echo "python3 not available"
fi

Repository: synthesizer-project/synference

Length of output: 279


🌐 Web query:

PyTorch RuntimeError expected index Tensor to be on the same device as the base tensor boolean mask device mismatch indexing CUDA tensor CPU tensor

💡 Result:

The PyTorch RuntimeError "expected index Tensor to be on the same device as the base tensor" (or similar device mismatch errors) occurs because PyTorch operations, including indexing and masking, require all input tensors to reside on the same device [1][2][3]. Unlike some other operations, PyTorch does not automatically transfer tensors between devices (e.g., from CPU to GPU) during indexing to prevent unexpected performance bottlenecks and silent data movement [1]. To resolve this issue, you must ensure that your index tensor (the boolean mask or integer index) and the base tensor (the data being indexed) are explicitly placed on the same device [1][3]. How to fix it: 1. Identify the device of your tensors: You can check the device of any tensor by inspecting its.device attribute [3]. print(base_tensor.device) print(mask_tensor.device) 2. Move the index/mask tensor to the target device: If your base tensor is on the GPU (e.g., 'cuda:0') and your mask is on the 'cpu', move the mask to the GPU using the.to method [1][4][5]. mask_tensor = mask_tensor.to(base_tensor.device) # Now perform the indexing result = base_tensor[mask_tensor] Common scenarios for this error: - Data loaders: Tensors created in a custom Dataset or during data preprocessing often default to the CPU. If you move your model to the GPU but forget to move these auxiliary tensors, the error will trigger [1]. - Boolean masks: Masks created using conditions (e.g., mask = tensor > 0) inherit the device of the original tensor. However, if you create a mask manually (e.g., using torch.ones or torch.tensor) without specifying the device, it will default to 'cpu' [5]. Always specify the device when creating new tensors intended for GPU operations [5]: mask = torch.ones(size, device=base_tensor.device, dtype=torch.bool) - Library functions: Some functions, such as those inside nn.functional or third-party modules, might internally create masks or buffers on the CPU [5][6]. Ensure any tensors passed to these functions are on the same device as the function's expected inputs [2][5]. If you are using a specific device like MPS (Apple Silicon), ensure you are on a recent PyTorch version, as older versions had limited support for cross-device indexing on non-CUDA backends [7].

Citations:


Move condition masks to self.device before indexing.

_as_condition_mask() creates boolean masks on CPU, but _init_x_T, _prepare_x_o, _extract_latents, and log_prob index tensors allocated on self.device. That causes cross-device indexing failures on CUDA; move the masks in _as_condition_mask() and handle the sample_intervals constraint_mask/effective_condition conversion consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/simformer/model.py` around lines 152 - 160, The
_as_condition_mask method currently returns CPU masks that are later used to
index device-local tensors. Move the validated mask to self.device before
returning it, and update sample_intervals so constraint_mask and
effective_condition conversions follow the same device placement; ensure
_init_x_T, _prepare_x_o, _extract_latents, and log_prob can index without
cross-device errors.

Comment on lines +277 to +281
# Validation split (first fraction of rows, as in the original implementation).
n_val = max(int(train_config["validation_fraction"] * data.shape[0]), 0)
data_t = torch.as_tensor(data, device=device)
data_val = data_t[:n_val].repeat(train_config["val_repeat"], 1) if n_val > 0 else None
data_train = data_t[n_val:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validation split takes the leading rows without shuffling.

data_val is the first validation_fraction of rows as supplied. Callers that pass a library in generation order (e.g. sorted by a parameter) get a systematically biased validation set, which then drives early stopping. The Simformer_Fitter path happens to shuffle upstream, but direct train_simformer users are exposed.

A seeded permutation before splitting would remove the dependency on caller ordering:

🛡️ Proposed fix
     n_val = max(int(train_config["validation_fraction"] * data.shape[0]), 0)
+    perm = torch.randperm(data.shape[0], generator=generator).numpy()
+    data = data[perm]
     data_t = torch.as_tensor(data, device=device)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Validation split (first fraction of rows, as in the original implementation).
n_val = max(int(train_config["validation_fraction"] * data.shape[0]), 0)
data_t = torch.as_tensor(data, device=device)
data_val = data_t[:n_val].repeat(train_config["val_repeat"], 1) if n_val > 0 else None
data_train = data_t[n_val:]
# Validation split (first fraction of rows, as in the original implementation).
n_val = max(int(train_config["validation_fraction"] * data.shape[0]), 0)
perm = torch.randperm(data.shape[0], generator=generator).numpy()
data = data[perm]
data_t = torch.as_tensor(data, device=device)
data_val = data_t[:n_val].repeat(train_config["val_repeat"], 1) if n_val > 0 else None
data_train = data_t[n_val:]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/simformer/train.py` around lines 277 - 281, Shuffle the input
rows with a seeded permutation before computing the validation split in
train_simformer, so data_val is sampled independently of caller ordering while
preserving reproducibility. Apply the same permuted tensor to both data_val and
data_train, and retain the existing validation repeat and empty-validation
behavior.

Comment on lines +374 to +394
if val_loss < min_val_loss:
min_val_loss = val_loss
best_state = copy.deepcopy({k: v.cpu() for k, v in net.state_dict().items()})
if early_stopping_counter > train_config["stop_early_count"]:
stats["early_stopped"] = True
if verbose:
logger.info(f"Early stopping at step {step} (val loss {val_loss:.4f}).")
break

if verbose and (step % print_every) == 0:
message = f"Step {step}/{total_steps}: train loss {train_loss_ema:.4f}"
if stats["val_loss"]:
message += f", val loss {stats['val_loss'][-1]:.4f}"
logger.info(message)
if progress_callback is not None:
progress_callback(
step, train_loss_ema, stats["val_loss"][-1] if stats["val_loss"] else None
)

if stats["early_stopped"] and best_state is not None:
net.load_state_dict(best_state)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Best checkpoint is discarded when training runs to completion.

best_state is tracked on every validation improvement but only restored under stats["early_stopped"]. A full-budget run therefore returns the final weights even when a clearly better validation point was seen earlier. Restoring best_state whenever it exists (or documenting the deliberate choice) would make the two paths consistent.

♻️ Proposed change
-    if stats["early_stopped"] and best_state is not None:
+    if best_state is not None:
         net.load_state_dict(best_state)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if val_loss < min_val_loss:
min_val_loss = val_loss
best_state = copy.deepcopy({k: v.cpu() for k, v in net.state_dict().items()})
if early_stopping_counter > train_config["stop_early_count"]:
stats["early_stopped"] = True
if verbose:
logger.info(f"Early stopping at step {step} (val loss {val_loss:.4f}).")
break
if verbose and (step % print_every) == 0:
message = f"Step {step}/{total_steps}: train loss {train_loss_ema:.4f}"
if stats["val_loss"]:
message += f", val loss {stats['val_loss'][-1]:.4f}"
logger.info(message)
if progress_callback is not None:
progress_callback(
step, train_loss_ema, stats["val_loss"][-1] if stats["val_loss"] else None
)
if stats["early_stopped"] and best_state is not None:
net.load_state_dict(best_state)
if val_loss < min_val_loss:
min_val_loss = val_loss
best_state = copy.deepcopy({k: v.cpu() for k, v in net.state_dict().items()})
if early_stopping_counter > train_config["stop_early_count"]:
stats["early_stopped"] = True
if verbose:
logger.info(f"Early stopping at step {step} (val loss {val_loss:.4f}).")
break
if verbose and (step % print_every) == 0:
message = f"Step {step}/{total_steps}: train loss {train_loss_ema:.4f}"
if stats["val_loss"]:
message += f", val loss {stats['val_loss'][-1]:.4f}"
logger.info(message)
if progress_callback is not None:
progress_callback(
step, train_loss_ema, stats["val_loss"][-1] if stats["val_loss"] else None
)
if best_state is not None:
net.load_state_dict(best_state)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/synference/simformer/train.py` around lines 374 - 394, Update the final
checkpoint restoration condition around best_state so the model loads the best
validation checkpoint whenever best_state is available, regardless of whether
stats["early_stopped"] is true. Preserve the existing guard that avoids loading
when no validation checkpoint was recorded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant