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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.26.1] - 2026-11-11

### Fixed

- `cpm.utils.metad.count_trials` now infers responses from accuracy when only accuracy is given, checks that responses and accuracy agree when both are given, and accepts arrays without a DataFrame; it previously returned all-zero counts when the responses column was missing. It now warns when it omits stimuli not coded 0/1, and raises an error instead of returning all-zero counts when no valid trials remain ([#106](https://github.com/DevComPsy/cpm/issues/106))
- `cpm.applications.signal_detection.EstimatorMetaD` names the participant when counting their trials fails ([#106](https://github.com/DevComPsy/cpm/issues/106))

## [0.26.0] - 2026-10-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion cpm/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.26.0"
__version__ = "0.26.1"
25 changes: 15 additions & 10 deletions cpm/applications/signal_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,16 +556,21 @@ def __task(participant, **args):
nRatings = self.bins
nCriteria = int(2 * nRatings - 1) # number criteria to be fitted

nR_S1, nR_S2 = count_trials(
data=subject,
stimuli="signal",
responses="response",
accuracy="accuracy",
confidence="discrete",
nRatings=nRatings,
padding=True,
padAmount=1/nRatings,
)
try:
nR_S1, nR_S2 = count_trials(
data=subject,
stimuli="signal",
responses="response",
accuracy="accuracy",
confidence="discrete",
nRatings=nRatings,
padding=True,
padAmount=1/nRatings,
)
except ValueError as e:
raise ValueError(
f"Error counting trials for participant {ppt}: {e}"
) from e

results_dict = fit_metad(
nR_S1=nR_S1,
Expand Down
166 changes: 125 additions & 41 deletions cpm/utils/metad.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import warnings

import numpy as np
import pandas as pd


def _is_column(data: pd.DataFrame, name) -> bool:
"""Whether `name` is given and names a column of `data`."""
return name is not None and name in data.columns


def count_trials(
data=pd.DataFrame,
data: pd.DataFrame | None = None,
stimuli: str = "Stimuli",
responses: str = "Responses",
accuracy: str = "Accuracy",
Expand All @@ -21,18 +29,24 @@ def count_trials(
Parameters
----------
data :
Dataframe containing stimuli, accuracy and confidence ratings.
DataFrame containing the columns below, or `None` to pass the stimuli,
responses, accuracy and confidence ratings as arrays. Default is
`None`.
stimuli :
Stimuli ID (0 or 1). If a dataframe is provided, should be the name of
the column containing the stimuli ID. Default is `'Stimuli'`.
responses :
Response (0 or 1). If a dataframe is provided, should be the
name of the column containing the response accuracy. Default is
`'Responses'`.
Response (0 or 1). Optional, but at least one of `responses` and
`accuracy` is required. If a dataframe is provided, should be the name
of the column containing the responses; a name that is not a column of
`data` counts as not provided. With arrays, `None` or a string counts
as not provided. Default is `'Responses'`.
accuracy :
Response accuracy (0 or 1). If a dataframe is provided, should be the
name of the column containing the response accuracy. Default is
`'Accuracy'`.
Response accuracy (0 or 1). Optional, but at least one of `responses`
and `accuracy` is required. If a dataframe is provided, should be the
name of the column containing the response accuracy; a name that is
not a column of `data` counts as not provided. With arrays, `None` or
a string counts as not provided. Default is `'Accuracy'`.
confidence :
Confidence ratings. If a dataframe is provided, should be the name of
the column containing the confidence ratings. Default is
Expand All @@ -45,10 +59,9 @@ def count_trials(
If `True`, each response count in the output has the value of padAmount
added to it. Padding cells is desirable if trial counts of 0 interfere
with model fitting. If False, trial counts are not manipulated and 0s
may be present in the response count output. Default value for padding
is 0.
may be present in the response count output. Default is `False`.
padAmount :
The value to add to each response count if padding is set to 1.
The value to add to each response count if padding is `True`.
Default value is 1/(2*nRatings)

Returns
Expand All @@ -57,14 +70,28 @@ def count_trials(
Vectors containing the total number of responses in each accuracy
category, conditional on presentation of S1 and S2.

Raises
------
ValueError
If `data` is neither `None` nor a DataFrame; if neither `responses`
nor `accuracy` is provided; if the inputs have different lengths; if
`responses` and `accuracy` disagree on a valid trial; or if no valid
trials remain.

Notes
-----
All trials where `stimuli` is not 0 or 1, accuracy is not 0 or 1, or confidence is
not in the range [1, nRatings], are automatically omitted.

The inputs can be responses, accuracy or both. If both `responses` and
`accuracy` are provided, will check for consstency. If only `accuracy` is
provided, the responses vector will be automatically infered.
All trials where `stimuli` is not 0 or 1, the response is not 0 or 1, or
confidence is not a whole number in the range [1, nRatings], are
automatically omitted. When `accuracy` is provided, trials where accuracy
is not 0 or 1 (including missing values) are omitted as well. Stimuli must
be coded 0 for S1 and 1 for S2: a `UserWarning` is issued when stimuli
other than 0, 1 or missing values are omitted, for example with 1/2 coding.

The inputs can be responses, accuracy or both. If only `accuracy` is
provided, the responses are inferred from it: a correct response equals the
stimulus, an incorrect one is the other stimulus. If both `responses` and
`accuracy` are provided, they are checked for consistency, and a
`ValueError` is raised on any trial where they disagree.

If nR_S1 = [100 50 20 10 5 1], then when stimulus S1 was presented, the subject had
the following accuracy counts::
Expand All @@ -89,13 +116,11 @@ def count_trials(

Examples
--------
>>> stimID = [0, 1, 0, 0, 1, 1, 1, 1]
>>> accuracy = [0, 1, 1, 1, 0, 0, 1, 1]
>>> confidence = [1, 2, 3, 4, 4, 3, 2, 1]
>>> nRatings = 4

>>> nR_S1, nR_S2 = count_trials(data, nRatings=4)
>>> print(nR_S1, nR_S2)
>>> from cpm.utils.metad import count_trials
>>> count_trials(stimuli=[0, 1, 0, 0, 1, 1, 1, 1],
... accuracy=[0, 1, 1, 1, 0, 0, 1, 1],
... confidence=[1, 2, 3, 4, 4, 3, 2, 1], nRatings=4)
(array([1, 1, 0, 0, 1, 0, 0, 0]), array([1, 1, 0, 0, 1, 2, 0, 0]))

References
----------
Expand All @@ -110,25 +135,84 @@ def count_trials(

"""
if isinstance(data, pd.DataFrame):
stimuli = data[stimuli].to_numpy()
confidence = data[confidence].to_numpy()
if accuracy in data:
accuracy = data[accuracy].to_numpy()
if responses in data:
responses = data[responses].to_numpy()
stimuli = data[stimuli]
confidence = data[confidence]
responses = data[responses] if _is_column(data, responses) else None
accuracy = data[accuracy] if _is_column(data, accuracy) else None
elif data is not None:
raise ValueError("`Data` should be a DataFrame")

# Check data consistency
tempstim, tempresp, tempratg = [], [], []
for s, rp, rt in zip(stimuli, responses, confidence):
if (s == 0 or s == 1) and (rp == 0 or rp == 1) and (rt >= 1 and rt <= nRatings):
tempstim.append(s)
tempresp.append(rp)
tempratg.append(rt)
stimuli = tempstim
responses = tempresp
confidence = tempratg
else:
# In array mode, a string is the column-name default left in place.
responses = None if isinstance(responses, str) else responses
accuracy = None if isinstance(accuracy, str) else accuracy

if responses is None and accuracy is None:
raise ValueError(
"count_trials needs `responses`, `accuracy`, or both; neither was provided."
)

stimuli = np.asarray(stimuli, dtype=float)
confidence = np.asarray(confidence, dtype=float)
inputs = [stimuli, confidence]
if responses is not None:
responses = np.asarray(responses, dtype=float)
inputs.append(responses)
if accuracy is not None:
accuracy = np.asarray(accuracy, dtype=float)
inputs.append(accuracy)
if len({len(x) for x in inputs}) > 1:
raise ValueError(
"`stimuli`, `responses`, `accuracy` and `confidence` must have the same "
f"length; got lengths {[len(x) for x in inputs]}."
)

binary_stimuli = np.isin(stimuli, (0, 1))
out_of_range = ~binary_stimuli & ~np.isnan(stimuli)
if out_of_range.any():
warnings.warn(
f"{out_of_range.sum()} trial(s) have stimuli outside {{0, 1}} (values "
f"{np.unique(stimuli[out_of_range])[:5].tolist()}) and are omitted; "
"code stimuli as 0 for S1 and 1 for S2.",
UserWarning,
stacklevel=2,
)
if accuracy is not None:
binary_accuracy = np.isin(accuracy, (0, 1))
if responses is None:
# A correct response matches the stimulus; an incorrect one does not.
# Trials with invalid stimuli or accuracy get NaN and are dropped below.
responses = np.where(
binary_stimuli & binary_accuracy,
np.where(accuracy == 1, stimuli, 1 - stimuli),
np.nan,
)

valid = (
binary_stimuli
& np.isin(responses, (0, 1))
& (confidence >= 1)
& (confidence <= nRatings)
& (confidence == np.round(confidence))
)
if accuracy is not None:
valid &= binary_accuracy
mismatch = valid & ((responses == stimuli) != (accuracy == 1))
if mismatch.any():
positions = np.flatnonzero(mismatch)
raise ValueError(
f"`responses` and `accuracy` are inconsistent on {len(positions)} "
f"trial(s), at positions {positions[:5].tolist()}"
f"{' and more' if len(positions) > 5 else ''}: a response is "
"correct when it equals the stimulus."
)
if not valid.any():
raise ValueError(
"count_trials found no valid trials: `stimuli` and responses must be 0 "
f"or 1, and `confidence` must be in [1, {nRatings}]."
)
stimuli = stimuli[valid]
responses = responses[valid]
confidence = confidence[valid]

if padAmount is None:
padAmount = 1 / (2 * nRatings)
Expand Down
16 changes: 15 additions & 1 deletion test/applications/test_signal_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def synthetic_data():
"signal": [0, 1, 0, 1],
"response": [0, 1, 0, 1],
"confidence": [1, 2, 2, 1],
"accuracy": [1, 0, 1, 1],
"accuracy": [1, 1, 1, 1],
"observed": [1, 0, 1, 0],
})

Expand Down Expand Up @@ -117,3 +117,17 @@ def test_metad_nll_equals_the_scipy_computation():
args = (nR_S1, nR_S2, n_ratings, rng.uniform(0.2, 2), rng.normal(0, 0.5), 1)
assert signal_detection.metad_nll(guess, *args) == pytest.approx(
_metad_nll_with_scipy(guess, *args), rel=1e-14)


def test_estimatormetad_names_participant_on_count_error(synthetic_data):
data = synthetic_data.copy()
data.loc[3, "accuracy"] = 0 # participant 2: signal 1, response 1, incorrect
est = signal_detection.EstimatorMetaD(
data=data.groupby("participant"),
bins=2,
parallel=False,
display=0,
ppt_identifier="participant",
)
with pytest.raises(ValueError, match="participant 2.*inconsistent"):
est.optimise()
Loading
Loading