Skip to content

Commit cd2729d

Browse files
edenoclaude
andcommitted
Allow per-state discrete transition prior_weight
Generalize prior_weight in estimate_stationary_state_transition to accept a scalar or per-state array. Rows with prior_weight[i] == 0 use the legacy fixed-count Dirichlet prior, while rows with prior_weight[i] > 0 use the data-adaptive scaling. This lets frozen structural rows (e.g. very sticky Local/No-Spike states) coexist with learnable rows that want size-invariant regularization in the same EM update. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5182492 commit cd2729d

8 files changed

Lines changed: 168 additions & 30 deletions

src/non_local_detector/discrete_state_transitions.py

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ def estimate_stationary_state_transition(
354354
acausal_posterior: np.ndarray,
355355
stickiness: float = 0.0,
356356
concentration: float = 1.0,
357-
prior_weight: float = 0.0,
357+
prior_weight: float | np.ndarray = 0.0,
358358
) -> np.ndarray:
359359
"""Estimate the stationary state transition model.
360360
@@ -366,20 +366,38 @@ def estimate_stationary_state_transition(
366366
acausal_posterior : np.ndarray, shape (n_time, n_states)
367367
stickiness : float, optional
368368
concentration : float, optional
369-
prior_weight : float, optional
369+
prior_weight : float or np.ndarray, shape (n_states,), optional
370370
Dimensionless weight for data-adaptive prior scaling. When > 0,
371-
the effective prior pseudo-counts are scaled by the expected
372-
transition count per state, making the prior influence approximately
373-
invariant to the number of time bins. When 0.0 (default), the
374-
fixed-count prior from ``concentration`` and ``stickiness`` is used
375-
directly (legacy behavior).
371+
the effective prior pseudo-counts for that row are scaled by the
372+
expected transition count N_i, making the prior influence
373+
approximately invariant to the number of time bins.
374+
375+
Can be a scalar (same weight for all rows) or an array with one
376+
value per state. Rows with ``prior_weight[i] == 0`` use the legacy
377+
fixed-count prior from ``concentration`` and ``stickiness``
378+
directly. This allows mixing frozen rows (e.g., structural states
379+
with very high stickiness) with data-adaptive rows in the same
380+
call. When 0.0 (default), all rows use legacy behavior.
376381
377382
Returns
378383
-------
379384
new_transition_matrix : np.ndarray, shape (n_states, n_states)
380385
"""
381-
if prior_weight < 0:
382-
raise ValueError(f"prior_weight must be non-negative, got {prior_weight}")
386+
n_states = acausal_posterior.shape[1]
387+
388+
# Normalize prior_weight to shape (n_states,)
389+
prior_weight_arr = np.atleast_1d(np.asarray(prior_weight, dtype=float))
390+
if prior_weight_arr.size == 1:
391+
prior_weight_arr = np.full(n_states, prior_weight_arr.item())
392+
if prior_weight_arr.shape != (n_states,):
393+
raise ValueError(
394+
f"prior_weight must be a scalar or array of shape ({n_states},), "
395+
f"got shape {prior_weight_arr.shape}"
396+
)
397+
if np.any(prior_weight_arr < 0):
398+
raise ValueError(
399+
f"prior_weight must be non-negative, got {prior_weight_arr}"
400+
)
383401

384402
# p(x_t, x_{t+1} | O_{1:T})
385403
joint_distribution = estimate_joint_distribution(
@@ -389,12 +407,14 @@ def estimate_stationary_state_transition(
389407
acausal_posterior,
390408
)
391409

392-
n_states = acausal_posterior.shape[1]
393410
joint_sum = joint_distribution.sum(axis=0) # (n_states, n_states)
394411

395412
alpha = get_transition_prior(concentration, stickiness, n_states)
396413

397-
if prior_weight > 0.0:
414+
# Legacy prior for rows with prior_weight[i] == 0
415+
legacy_prior = alpha - 1.0 # (n_states, n_states)
416+
417+
if np.any(prior_weight_arr > 0):
398418
# Data-adaptive prior: scale pseudo-counts by expected transitions
399419
# so regularization strength is invariant to temporal resolution.
400420
alpha_shape = alpha - 1.0 # (n_states, n_states)
@@ -407,12 +427,21 @@ def estimate_stationary_state_transition(
407427
# N_i = expected transitions from state i
408428
N_i = joint_sum.sum(axis=-1, keepdims=True) # (n_states, 1)
409429

410-
# alpha_eff - 1 = prior_weight * N_i * tilde_alpha
411-
effective_prior = prior_weight * N_i * tilde_alpha
412-
new_transition_matrix = joint_sum + effective_prior
430+
# Per-row adaptive prior: prior_weight[i] * N_i * tilde_alpha[i]
431+
adaptive_prior = prior_weight_arr[:, np.newaxis] * N_i * tilde_alpha
432+
433+
# Use adaptive prior for rows where prior_weight > 0, legacy otherwise
434+
use_adaptive = prior_weight_arr > 0 # (n_states,)
435+
effective_prior = np.where(
436+
use_adaptive[:, np.newaxis],
437+
adaptive_prior,
438+
legacy_prior,
439+
)
413440
else:
414-
# Legacy fixed-count prior
415-
new_transition_matrix = joint_sum + alpha - 1.0
441+
# All rows use legacy fixed-count prior
442+
effective_prior = legacy_prior
443+
444+
new_transition_matrix = joint_sum + effective_prior
416445

417446
# Normalize rows to get transition probabilities.
418447
# Guard against all-zero rows (e.g., unvisited states) to avoid NaN.
@@ -607,7 +636,7 @@ def _estimate_discrete_transition(
607636
transition_concentration: float,
608637
transition_stickiness: float | np.ndarray,
609638
transition_regularization: float,
610-
transition_prior_weight: float = 0.0,
639+
transition_prior_weight: float | np.ndarray = 0.0,
611640
) -> tuple[np.ndarray, np.ndarray]:
612641
"""Estimate the discrete transition matrix (stationary or non-stationary).
613642

src/non_local_detector/models/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def __init__(
206206
state_names: StateNames = None,
207207
sampling_frequency: float = 500.0,
208208
no_spike_rate: float = 1e-10,
209-
discrete_transition_prior_weight: float = 0.0,
209+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
210210
) -> None:
211211
"""
212212
Initialize the _DetectorBase class.
@@ -2099,7 +2099,7 @@ def __init__(
20992099
state_names: StateNames = None,
21002100
sampling_frequency: float = 500.0,
21012101
no_spike_rate: float = 1e-10,
2102-
discrete_transition_prior_weight: float = 0.0,
2102+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
21032103
) -> None:
21042104
"""
21052105
Initialize the ClusterlessDetector class.
@@ -2934,7 +2934,7 @@ def __init__(
29342934
state_names: StateNames = None,
29352935
sampling_frequency: float = 500.0,
29362936
no_spike_rate: float = 1e-10,
2937-
discrete_transition_prior_weight: float = 0.0,
2937+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
29382938
) -> None:
29392939
"""
29402940
Initialize the SortedSpikesDetector class.

src/non_local_detector/models/cont_frag_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def __init__(
103103
state_names: StateNames | None = None,
104104
sampling_frequency: float = 500,
105105
no_spike_rate: float = 1e-10,
106-
discrete_transition_prior_weight: float = 0.0,
106+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
107107
):
108108
params = _initialize_params(
109109
_ModelDefaults.cont_frag_defaults(),
@@ -250,7 +250,7 @@ def __init__(
250250
state_names: StateNames | None = None,
251251
sampling_frequency: float = 500.0,
252252
no_spike_rate: float = 1e-10,
253-
discrete_transition_prior_weight: float = 0.0,
253+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
254254
):
255255
params = _initialize_params(
256256
_ModelDefaults.cont_frag_defaults(),

src/non_local_detector/models/decoder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def __init__(
8585
state_names: StateNames | None = None,
8686
sampling_frequency: float = 500,
8787
no_spike_rate: float = 1e-10,
88-
discrete_transition_prior_weight: float = 0.0,
88+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
8989
):
9090
params = _initialize_params(
9191
_ModelDefaults.decoder_defaults(),
@@ -183,7 +183,7 @@ def __init__(
183183
state_names: StateNames | None = None,
184184
sampling_frequency: float = 500.0,
185185
no_spike_rate: float = 1e-10,
186-
discrete_transition_prior_weight: float = 0.0,
186+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
187187
):
188188
params = _initialize_params(
189189
_ModelDefaults.decoder_defaults(),

src/non_local_detector/models/multienvironment_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def __init__(
104104
state_names: StateNames | None = None,
105105
sampling_frequency: float = 500,
106106
no_spike_rate: float = 1e-10,
107-
discrete_transition_prior_weight: float = 0.0,
107+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
108108
):
109109
params = _initialize_params(
110110
_ModelDefaults.multi_environment_defaults(),
@@ -220,7 +220,7 @@ def __init__(
220220
state_names: StateNames | None = None,
221221
sampling_frequency: float = 500.0,
222222
no_spike_rate: float = 1e-10,
223-
discrete_transition_prior_weight: float = 0.0,
223+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
224224
):
225225
params = _initialize_params(
226226
_ModelDefaults.multi_environment_defaults(),

src/non_local_detector/models/non_local_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def __init__(
118118
no_spike_rate: float = 1e-10,
119119
non_local_position_penalty: float = 0.0,
120120
non_local_penalty_sigma: float = 1.0,
121-
discrete_transition_prior_weight: float = 0.0,
121+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
122122
):
123123
params = _initialize_params(
124124
_ModelDefaults.non_local_defaults(),
@@ -251,7 +251,7 @@ def __init__(
251251
no_spike_rate: float = 1e-10,
252252
non_local_position_penalty: float = 0.0,
253253
non_local_penalty_sigma: float = 1.0,
254-
discrete_transition_prior_weight: float = 0.0,
254+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
255255
):
256256
params = _initialize_params(
257257
_ModelDefaults.non_local_defaults(),

src/non_local_detector/models/nospike_cont_frag_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def __init__(
108108
state_names: StateNames | None = None,
109109
sampling_frequency: float = 500.0,
110110
no_spike_rate: float = 1e-10,
111-
discrete_transition_prior_weight: float = 0.0,
111+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
112112
):
113113
params = _initialize_params(
114114
_ModelDefaults.nospike_cont_frag_defaults(),
@@ -229,7 +229,7 @@ def __init__(
229229
state_names: StateNames | None = None,
230230
sampling_frequency: float = 500.0,
231231
no_spike_rate: float = 1e-10,
232-
discrete_transition_prior_weight: float = 0.0,
232+
discrete_transition_prior_weight: float | np.ndarray = 0.0,
233233
):
234234
params = _initialize_params(
235235
_ModelDefaults.nospike_cont_frag_defaults(),

src/non_local_detector/tests/test_discrete_transitions_estimation.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,115 @@ def test_prior_weight_handles_unvisited_state(self):
429429
"Unvisited state should reflect sticky prior, not uniform"
430430
)
431431

432+
def test_per_state_prior_weight_scalar_equivalence(self, posterior_data):
433+
"""Passing scalar prior_weight should match passing uniform array."""
434+
post = posterior_data
435+
n_states = post["n_states"]
436+
437+
scalar_result = estimate_stationary_state_transition(
438+
causal_posterior=post["causal_posterior"],
439+
predictive_distribution=post["predictive_distribution"],
440+
acausal_posterior=post["acausal_posterior"],
441+
transition_matrix=post["transition_matrix"],
442+
concentration=1.0,
443+
stickiness=2.0,
444+
prior_weight=0.1,
445+
)
446+
array_result = estimate_stationary_state_transition(
447+
causal_posterior=post["causal_posterior"],
448+
predictive_distribution=post["predictive_distribution"],
449+
acausal_posterior=post["acausal_posterior"],
450+
transition_matrix=post["transition_matrix"],
451+
concentration=1.0,
452+
stickiness=2.0,
453+
prior_weight=np.full(n_states, 0.1),
454+
)
455+
456+
np.testing.assert_allclose(scalar_result, array_result, atol=1e-12)
457+
458+
def test_per_state_prior_weight_mixed_modes(self, posterior_data):
459+
"""Rows with prior_weight=0 use legacy; rows with >0 use data-adaptive."""
460+
post = posterior_data
461+
n_states = post["n_states"]
462+
463+
# State 0 uses legacy path (strong fixed prior), others use adaptive
464+
prior_weight = np.array([0.0] + [0.1] * (n_states - 1))
465+
sticky = np.array([1e6] + [2.0] * (n_states - 1))
466+
467+
result = estimate_stationary_state_transition(
468+
causal_posterior=post["causal_posterior"],
469+
predictive_distribution=post["predictive_distribution"],
470+
acausal_posterior=post["acausal_posterior"],
471+
transition_matrix=post["transition_matrix"],
472+
concentration=1.0,
473+
stickiness=sticky,
474+
prior_weight=prior_weight,
475+
)
476+
477+
assert_stochastic_matrix(result)
478+
# State 0 (frozen) should have near-1 diagonal due to huge stickiness
479+
assert result[0, 0] > 0.99
480+
481+
def test_per_state_prior_weight_row_0_frozen_matches_legacy(self, posterior_data):
482+
"""For frozen row (prior_weight=0), result should match pure legacy call."""
483+
post = posterior_data
484+
n_states = post["n_states"]
485+
486+
sticky = np.array([1e6] + [2.0] * (n_states - 1))
487+
488+
legacy = estimate_stationary_state_transition(
489+
causal_posterior=post["causal_posterior"],
490+
predictive_distribution=post["predictive_distribution"],
491+
acausal_posterior=post["acausal_posterior"],
492+
transition_matrix=post["transition_matrix"],
493+
concentration=1.0,
494+
stickiness=sticky,
495+
prior_weight=0.0,
496+
)
497+
mixed = estimate_stationary_state_transition(
498+
causal_posterior=post["causal_posterior"],
499+
predictive_distribution=post["predictive_distribution"],
500+
acausal_posterior=post["acausal_posterior"],
501+
transition_matrix=post["transition_matrix"],
502+
concentration=1.0,
503+
stickiness=sticky,
504+
prior_weight=np.array([0.0] + [0.1] * (n_states - 1)),
505+
)
506+
507+
# Row 0 (legacy) should match in both calls
508+
np.testing.assert_allclose(mixed[0], legacy[0], atol=1e-12)
509+
510+
def test_per_state_prior_weight_negative_raises(self, posterior_data):
511+
"""Negative per-state prior_weight should raise ValueError."""
512+
post = posterior_data
513+
n_states = post["n_states"]
514+
515+
with pytest.raises(ValueError, match="non-negative"):
516+
estimate_stationary_state_transition(
517+
causal_posterior=post["causal_posterior"],
518+
predictive_distribution=post["predictive_distribution"],
519+
acausal_posterior=post["acausal_posterior"],
520+
transition_matrix=post["transition_matrix"],
521+
concentration=1.0,
522+
stickiness=2.0,
523+
prior_weight=np.array([0.1, -0.1] + [0.1] * (n_states - 2)),
524+
)
525+
526+
def test_per_state_prior_weight_wrong_shape_raises(self, posterior_data):
527+
"""prior_weight array with wrong length should raise ValueError."""
528+
post = posterior_data
529+
530+
with pytest.raises(ValueError, match="prior_weight"):
531+
estimate_stationary_state_transition(
532+
causal_posterior=post["causal_posterior"],
533+
predictive_distribution=post["predictive_distribution"],
534+
acausal_posterior=post["acausal_posterior"],
535+
transition_matrix=post["transition_matrix"],
536+
concentration=1.0,
537+
stickiness=2.0,
538+
prior_weight=np.array([0.1, 0.1]), # wrong length
539+
)
540+
432541

433542
@pytest.mark.unit
434543
class TestEstimateDiscreteTransition:

0 commit comments

Comments
 (0)