Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ and **Plan Analysis** v0.5.
| `PsychometricFitConfig` | Lookup | One versioned psychometric eligibility configuration | none | minimum choices/stimulus values, analysis version |
| `PsychometricSessionFit` | Computed | One fit for one upstream TrialSet and config | `DecisionTask.TrialSet` + config | status, fit sample size, curve/parameters/diagnostics |
| `PsychometricSubjectFit` | Computed | One pooled fit for one analysis set, subject, condition, and config | analysis set + `Subject` + config | status, fit sample size, curve/parameters/diagnostics |
| `PsychophysicalKernelFitConfig` | Lookup | One versioned pooled-kernel configuration | none | binning method, observation window, evidence encoding, CV, regularization, version |
| `PsychophysicalKernelFitConfig` | Lookup | One integer-keyed pooled-kernel configuration | none | binning method, observation window, evidence model, CV, regularization |
| `PsychophysicalKernel` | Computed | One pooled kernel for one analysis set, subject, condition, and config | analysis set + `Subject` + kernel config | status, timing source, fit sample size, weights, held-out scores, bias |

Keep:
Expand Down Expand Up @@ -133,9 +133,12 @@ subpage.

## Kernel timing-source extension

The canonical pooled kernel now supports fixed 100 ms windows in addition to
the migrated variable-width `v1_10bin_10fold` fit. For fixed-window configs,
each selected session:
The canonical pooled kernel uses fixed 100 ms bins and the Odoemene et al.
(2018) Equation 5 evidence model: each bin's flash count is centered on that
trial's generative stimulus rate, which is also included as a separate nuisance
regressor. Config IDs are opaque integers: config 0 uses the center-exit window
and config 1 uses the response window. The lookup parameters, not the ID,
define each analysis. For each selected session:

1. uses NIDAQ/OneBox visual-flash and port-event times when a `visual_stim`
`EventMapping` exists and all required mappings validate;
Expand All @@ -144,6 +147,10 @@ each selected session:
3. raises on incomplete or invalid NIDAQ mappings rather than silently falling
back to Bpod.

Within a NIDAQ-timed session, mapped flashes and port edges must agree with the
Bpod trial alignment within 100 ms. Trials with missing or misaligned hardware
events are excluded rather than replaced with Bpod timestamps.

`PsychophysicalKernel.timing_source` records `nidaq`, `bpod`, or `mixed` for the
pooled result. Detailed session provenance remains derivable from
`BehaviorAnalysisSet.TrialSet` and the ephys `EventMapping`; no duplicate
Expand All @@ -156,7 +163,6 @@ uv run python scripts/analyses/migrate_kernel_timing_source.py
uv run python scripts/analyses/migrate_kernel_timing_source.py --apply
```

The first command is read-only and prints the missing `ALTER TABLE` statements.
The second adds the new nullable/defaulted columns and inserts the two v2
fixed-window configs while preserving all existing v1 rows as Bpod-timed
legacy fits.
The first command is read-only and reports the rows that will be removed. The
second recreates the disposable kernel result and config tables with the
canonical schema. Existing kernel rows are not migrated.
141 changes: 25 additions & 116 deletions labdata_plugin/analysisschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,65 +148,45 @@ def make(self, key):

@rojasbowe_schema
class PsychophysicalKernelFitConfig(dj.Lookup):
"""Versioned settings for pooled psychophysical-kernel fits."""
"""Settings for pooled psychophysical-kernel fits."""

definition = """
kernel_fit_config_id : varchar(48)
kernel_fit_config_id : int
---
timebins : int
binning_method : enum('fixed_width')
bin_width_s : float
observation_window : enum('center_exit', 'response')
evidence_model : enum('trial_rate_residual') # Odoemene Eq. 5
min_trials_per_bin : int
cv_splits : int
random_state : int
max_rate_hz : float # calibration rate
regularization_c : float
kernel_method = 'legacy_variable' : enum('legacy_variable', 'fixed_window')
bin_width_s = NULL : float
evidence_encoding = NULL : enum('max_rate', 'trial_rate')
min_trials_per_bin = NULL : int
observation_window = NULL : enum('center_exit', 'response')
analysis_version : varchar(32)
"""
contents = [ # noqa: RUF012
(
"v1_10bin_10fold",
0,
10,
"fixed_width",
0.1,
"center_exit",
"trial_rate_residual",
50,
10,
0,
20.0,
1.0,
"legacy_variable",
None,
None,
None,
None,
"v1",
),
(
"v2_100ms_10bin_center_rate",
10,
1,
10,
0,
20.0,
1.0,
"fixed_window",
"fixed_width",
0.1,
"trial_rate",
"response",
"trial_rate_residual",
50,
"center_exit",
"v2",
),
(
"v2_100ms_10bin_response_rate",
10,
10,
0,
20.0,
1.0,
"fixed_window",
0.1,
"trial_rate",
50,
"response",
"v2",
),
]

Expand Down Expand Up @@ -251,10 +231,7 @@ def key_source(self):
def make(self, key):
config = (PsychophysicalKernelFitConfig() & key).fetch1()
trialset_keys = _selected_trialset_keys(key)
if config["kernel_method"] == "legacy_variable":
payload = _legacy_kernel_payload(key, config, trialset_keys)
else:
payload = _fixed_kernel_payload(key, config, trialset_keys)
payload = _kernel_payload(key, config, trialset_keys)
self.insert1(
{
**key,
Expand Down Expand Up @@ -284,73 +261,11 @@ def _fetch_trialset_rows_for_subject(key):
return list((DecisionTask.TrialSet() & trialset_keys).fetch(as_dict=True))


def _legacy_kernel_payload(key, config, trialset_keys):
from behavior_analyses.io import get_chipmunk_table
from behavior_analyses.kernels import (
fit_psychophysical_kernel,
interpret_kernel_weights,
)

Chipmunk = get_chipmunk_table()
relation = (
Chipmunk() * Chipmunk.Trial() * Chipmunk.TrialParameters()
& trialset_keys
& {"rewarded_modality": key["trialset_description"]}
)
stim_events, response_values = relation.fetch("stim_events", "response")
result = fit_psychophysical_kernel(
stim_events,
response_values,
timebins=int(config["timebins"]),
cv_splits=int(config["cv_splits"]),
random_state=int(config["random_state"]),
max_rate_hz=float(config["max_rate_hz"]),
regularization_c=float(config["regularization_c"]),
)
n_trials_fit = int(result["choice_right"].size)
if result["weights"].size == 0:
return {
"fit_status": "skipped",
"fit_message": "insufficient trials or response classes for CV",
"timing_source": "bpod",
"n_trials_fit": n_trials_fit,
"n_bins_fit": 0,
}

n_observed = np.full(int(config["timebins"]), n_trials_fit, dtype=int)
score_mean = float(np.mean(result["scores"]))
majority_accuracy = float(
max(np.mean(result["choice_right"]), 1.0 - np.mean(result["choice_right"]))
)
weights_mean = np.mean(result["weights"], axis=0)
return {
"fit_status": "fit",
"timing_source": "bpod",
"n_trials_fit": n_trials_fit,
"n_bins_fit": int(config["timebins"]),
"n_observed_per_bin": n_observed,
"weights": result["weights"],
"weights_mean": weights_mean,
"weights_error": np.mean(result["error"], axis=0),
"scores": result["scores"],
"score_mean": score_mean,
"majority_accuracy": majority_accuracy,
"score_above_majority": score_mean - majority_accuracy,
"bias": result["bias"],
"bias_mean": float(np.mean(result["bias"])),
"interpretation": interpret_kernel_weights(
weights_mean,
n_observed,
min_trials_per_bin=int(config["cv_splits"]),
),
}


def _fixed_kernel_payload(key, config, trialset_keys):
def _kernel_payload(key, config, trialset_keys):
from behavior_analyses.kernel_timing import fetch_pooled_kernel_inputs
from behavior_analyses.kernels import (
build_fixed_residual_rate_matrix,
fit_fixed_psychophysical_kernel,
build_residual_rate_matrix,
fit_psychophysical_kernel,
interpret_kernel_weights,
)

Expand All @@ -359,32 +274,26 @@ def _fixed_kernel_payload(key, config, trialset_keys):
key["trialset_description"],
observation_window=str(config["observation_window"]),
)
trial_rate_hz = (
inputs["trial_rate_hz"] if config["evidence_encoding"] == "trial_rate" else None
)
residual, choices, n_observed, bin_centers, expected_counts = (
build_fixed_residual_rate_matrix(
build_residual_rate_matrix(
inputs["stim_times_per_trial"],
inputs["first_stim_times"],
inputs["observation_end_times"],
inputs["response_values"],
timebins=int(config["timebins"]),
bin_width_s=float(config["bin_width_s"]),
max_rate_hz=float(config["max_rate_hz"]),
trial_rate_hz=trial_rate_hz,
trial_rate_hz=inputs["trial_rate_hz"],
)
)
result = fit_fixed_psychophysical_kernel(
result = fit_psychophysical_kernel(
residual,
choices,
n_observed_per_bin=n_observed,
cv_splits=int(config["cv_splits"]),
random_state=int(config["random_state"]),
min_trials_per_bin=int(config["min_trials_per_bin"]),
regularization_c=float(config["regularization_c"]),
expected_counts=(
expected_counts if config["evidence_encoding"] == "trial_rate" else None
),
expected_counts=expected_counts,
)
base = {
"timing_source": inputs["timing_source"],
Expand Down
66 changes: 0 additions & 66 deletions scripts/analyses/migrate_behavior_analysis_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"__psychometric_subject_fit",
"__psychophysical_kernel",
}
EXPECTED_KERNEL_CONFIG = (10, 10, 0)
TRIALSET_KEY_FIELDS = (
"subject_name",
"session_name",
Expand Down Expand Up @@ -64,11 +63,9 @@ def main() -> None:
database = f"{labdata_schema.dbase_name}_user"
if args.resume:
_validate_resume_state(connection, database)
_validate_kernel_configs(connection, database, archived=True)
_print_archive_counts(connection, database)
else:
_validate_table_state(connection, database)
_validate_kernel_configs(connection, database)
_print_source_counts(connection, database)
if not args.apply:
print("Dry run only. Re-run with --apply after exact live-write approval.")
Expand All @@ -86,14 +83,9 @@ def main() -> None:
PsychometricSessionFit,
PsychometricSubjectFit,
PsychophysicalKernel,
PsychophysicalKernelFitConfig,
)

PsychometricFitConfig.insert1(("v1", 100, 6, "v1"), skip_duplicates=True)
PsychophysicalKernelFitConfig.insert1(
("v1_10bin_10fold", 10, 10, 0, 20.0, 1.0, "v1"),
skip_duplicates=True,
)

old_master = _archive_table(connection, database, "behavior_session_set")
BehaviorAnalysisSet.insert(
Expand Down Expand Up @@ -166,35 +158,6 @@ def main() -> None:
allow_direct_insert=True,
)

old_kernels = _archive_table(connection, database, "__psychophysical_kernel")
PsychophysicalKernel.insert(
[
{
"analysis_set_id": row["session_set_id"],
"subject_name": row["subject_name"],
"trialset_description": row["trialset_description"],
"kernel_fit_config_id": "v1_10bin_10fold",
"fit_status": "fit",
"n_trials_fit": row["n_trials"],
**{
field: row[field]
for field in (
"weights",
"weights_mean",
"weights_error",
"scores",
"score_mean",
"bias",
"bias_mean",
)
},
}
for row in old_kernels.fetch(as_dict=True)
],
skip_duplicates=True,
allow_direct_insert=True,
)

print(f"BehaviorAnalysisSet: {len(BehaviorAnalysisSet())}")
print(f"BehaviorAnalysisSet.TrialSet: {len(BehaviorAnalysisSet.TrialSet())}")
print(f"PsychometricSessionFit copied: {len(PsychometricSessionFit())}")
Expand Down Expand Up @@ -241,35 +204,6 @@ def _validate_resume_state(connection, database):
)


def _validate_kernel_configs(connection, database, *, archived=False):
master = (
ARCHIVE_TABLES["behavior_session_set"] if archived else "behavior_session_set"
)
kernel = (
ARCHIVE_TABLES["__psychophysical_kernel"]
if archived
else "__psychophysical_kernel"
)
queries = {
master: (
"SELECT DISTINCT kernel_timebins, kernel_cv_splits, "
f"kernel_random_state FROM `{database}`.`{master}`"
),
kernel: (
"SELECT DISTINCT timebins, cv_splits, random_state "
f"FROM `{database}`.`{kernel}`"
),
}
incompatible = {}
for table, query in queries.items():
configs = {tuple(map(int, row)) for row in connection.query(query).fetchall()}
unexpected = configs - {EXPECTED_KERNEL_CONFIG}
if unexpected:
incompatible[table] = sorted(unexpected)
if incompatible:
raise RuntimeError(f"Incompatible legacy kernel configs: {incompatible}")


def _print_source_counts(connection, database):
for source, archive in ARCHIVE_TABLES.items():
count = connection.query(
Expand Down
Loading
Loading