diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9bde6..9f2b51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Trials without a response in Visiomode 0.5+ sessions.** Newer Visiomode versions + record misses and correct rejections as a response named `"none"`, with the trial + timeout (4–10 s) as the response time. These trials were treated as having a + response, so the timeout was counted as a reaction time in the session and subject + RT metrics and report plots, the trials got a touch position of 0, and gonogo + regressors gave them a cued lever push response regressor instead of the hold + regressor. They are now treated as having no response + (empty `response`, `response_time` and position). `session.summary()` also blanks + `"none"` responses in trials CSVs written by earlier versions, so `visiomode-analysis + subject` gives correct RTs without re-running `session`. Lever push durations were + not affected. + ## [0.4.0] - 2026-10-02 ### Added diff --git a/src/visiomode_analysis/session/__init__.py b/src/visiomode_analysis/session/__init__.py index 4d56fad..6af4af4 100644 --- a/src/visiomode_analysis/session/__init__.py +++ b/src/visiomode_analysis/session/__init__.py @@ -57,6 +57,10 @@ # outcome vocabulary so downstream logic only has to deal with one set of labels. LEGACY_OUTCOME_LABELS = {"hit": "correct", "false_alarm": "incorrect", "miss": "no_response"} +# Newer Visiomode versions (0.5+) record a trial without a response (a miss or correct rejection) as a response with +# this name, with the trial timeout as its response time. Older versions leave the response out instead. +NO_RESPONSE_NAME = "none" + # Companion file holding the duration (ms) of every lever push in a session, one row per push in trial order. LEVER_DURATIONS_SUFFIX = "_lever-durations.csv" LEVER_DURATION = "lever_duration" @@ -397,6 +401,20 @@ def _lever_duration_summary(df: pd.DataFrame) -> dict[str, float]: return fields +def _blank_none_responses(df: pd.DataFrame) -> pd.DataFrame: + """Blank out the response and response time of trials whose response is `NO_RESPONSE_NAME`. + + `_flatten_trials` already does this for trials parsed from a JSON; this covers trials CSVs written by versions + before 0.4.1, which kept the "none" response and its timeout response time. + """ + no_response = df["response"] == NO_RESPONSE_NAME + if not no_response.any(): + return df + df = df.copy() + df.loc[no_response, ["response", "response_time"]] = np.nan + return df + + def get_rts(path: str, sdt_type=None, include_corrections=True) -> npt.NDArray: return _select_rts(get_trials(path=path), sdt_type=sdt_type, include_corrections=include_corrections) @@ -427,7 +445,7 @@ def summary(path: str | pd.DataFrame, lever_durations: str | None = None) -> dic metadata = get_metadata(path) df = get_trials(path, lever_durations=lever_durations) else: - df = path if isinstance(path, pd.DataFrame) else pd.read_csv(path) + df = _blank_none_responses(path if isinstance(path, pd.DataFrame) else pd.read_csv(path)) metadata = { "animal_id": df["animal_id"].iloc[0], "session_date": df["session_date"].iloc[0], @@ -839,11 +857,23 @@ def _normalise_legacy_outcome(trial: Any) -> Any: return {**trial, "outcome": LEGACY_OUTCOME_LABELS.get(trial["outcome"], trial["outcome"])} +def _normalise_no_response(trial: Any) -> Any: + """Return a copy of a raw trial with a `NO_RESPONSE_NAME` response rewritten the way older Visiomode versions + record no response: no response object and a response time of -1. + + Done up front so the response, response time, touch position, stimulus reconstruction and SDT inference in + `_flatten_trials` all treat the trial as having no response, rather than counting the timeout as a reaction time. + """ + if (trial.get("response") or {}).get("name") == NO_RESPONSE_NAME: + return {**trial, "response": None, "response_time": -1} + return trial + + def _flatten_trials(session: dict, metadata: dict) -> Iterator[dict]: session_start_time = datetime.datetime.fromisoformat(metadata.get("session_start_time", "")) for trial in session.get("trials", []): - trial = _normalise_legacy_outcome(trial) + trial = _normalise_no_response(_normalise_legacy_outcome(trial)) start_time = (datetime.datetime.fromisoformat(trial["timestamp"]) - session_start_time).total_seconds() diff --git a/tests/test_flatten_trials.py b/tests/test_flatten_trials.py index d50f616..210ae9f 100644 --- a/tests/test_flatten_trials.py +++ b/tests/test_flatten_trials.py @@ -1,10 +1,12 @@ -"""Tests for `session._flatten_trials`'s handling of older Visiomode JSON formats: sessions +"""Tests for `session._flatten_trials`'s handling of different Visiomode JSON formats: sessions recorded before an explicit `stimulus`/`sdt_type` field existed on each trial, where the presented stimulus and signal-detection classification instead have to be reconstructed from the trial's -`outcome`/`response` and the session-level `stimuli` metadata. +`outcome`/`response` and the session-level `stimuli` metadata, and newer (0.5+) sessions that record +a trial without a response as a response named "none". """ import numpy as np +import pytest BASE_TRIAL = dict( timestamp="2022-01-01T00:00:01", @@ -172,3 +174,59 @@ def test_legacy_other_protocol_uses_raw_stimuli_dict_unchanged(flatten_trial): assert trial["target_id"] == "movinggrating" assert trial["distractor_id"] == "isoluminantgray" assert "stim_id" not in trial + + +# -- Visiomode 0.5+ records "no response" as a response named "none", with the trial timeout as its response time. -- + +NEWER_STIMULUS = {"id": "movinggrating", "common_name": "Moving Grating"} + + +@pytest.mark.parametrize( + "outcome, sdt_type, timeout", + [("no_response", "miss", 10.001618658035703), ("correct", "correct_rejection", 4.000969302495185)], +) +def test_none_response_is_treated_as_no_response(flatten_trial, outcome, sdt_type, timeout): + trial = flatten_trial( + { + **BASE_TRIAL, + "outcome": outcome, + "sdt_type": sdt_type, + "response": {"name": "none"}, + "response_time": timeout, + "stimulus": NEWER_STIMULUS, + } + ) + + assert trial["response"] is None + # The timeout is not a reaction time. + assert np.isnan(trial["response_time"]) + assert trial["pos_x"] is None and trial["pos_y"] is None + assert trial["dist_x"] is None and trial["dist_y"] is None + assert trial["sdt_type"] == sdt_type + # No response timestamp, so the trial runs to the end of the stimulus. + assert trial["stop_time"] == pytest.approx(1.0 + BASE_TRIAL["iti"] + 4.0) + + +def test_named_lever_push_response_keeps_its_response_time(flatten_trial): + trial = flatten_trial( + { + **BASE_TRIAL, + "sdt_type": "hit", + "response": {"name": "leverpush", "timestamp": "2022-01-01T00:00:08", "pos_x": 400.0, "pos_y": 240.0}, + "response_time": 2.23, + "stimulus": NEWER_STIMULUS, + } + ) + + assert trial["response"] == "leverpush" + assert trial["response_time"] == pytest.approx(2.23) + assert trial["pos_x"] == 400.0 + + +def test_none_response_is_normalised_before_sdt_inference(flatten_trial): + # Without an explicit sdt_type, a "none" response must still be read as no response, + # so a correct gonogo trial is a correct rejection rather than a hit. + trial = flatten_trial({**BASE_TRIAL, "outcome": "correct", "response": {"name": "none"}, "response_time": 4.0}) + + assert trial["sdt_type"] == "correct_rejection" + assert trial["stim_id"] == "isoluminantgray" diff --git a/tests/test_session.py b/tests/test_session.py index 3ff9f76..e074951 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -512,3 +512,21 @@ def test_summary_reads_metadata_from_a_filtered_trials_dataframe(gonogo_session_ assert result["animal_id"] == "MM229" assert result["protocol"] == "gonogo" + + +def test_summary_ignores_none_responses_in_trials_csvs_written_by_older_versions(tmp_path, write_trials_csv): + # Trials CSVs written before 0.4.1 kept Visiomode's "none" response with the timeout as its response time. + rows = [ + dict(outcome="correct", correction=False, sdt_type="hit", response_time=0.5, response="leverpush"), + dict(outcome="incorrect", correction=False, sdt_type="false_alarm", response_time=0.7, response="leverpush"), + dict(outcome="no_response", correction=False, sdt_type="miss", response_time=10.0, response="none"), + dict(outcome="correct", correction=False, sdt_type="correct_rejection", response_time=4.0, response="none"), + ] + csv_path = write_trials_csv(tmp_path, "trials.csv", "A1", "2022-01-01", "gonogo", "expX", rows=rows) + + result = session.summary(csv_path) + + assert result["rt"] == pytest.approx(0.6) + assert result["rt_wc"] == pytest.approx(0.6) + assert result["rt_iqr"] == pytest.approx(0.1) + assert result["misses"] == 1 and result["correct_rejections"] == 1