Skip to content

Commit affbaf8

Browse files
authored
Merge pull request #502 from int-brain-lab/taskQC_GCaug2022
task QC
2 parents 6040f00 + 0fef759 commit affbaf8

File tree

4 files changed

+68
-23
lines changed

4 files changed

+68
-23
lines changed

ibllib/qc/camera.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ def check_wheel_alignment(self, tolerance=(1, 2), display=False):
594594

595595
# Determine the outcome. If there are two values for the tolerance, one is taken to be
596596
# a warning threshold, the other a failure threshold.
597-
out_map = {0: 'FAIL', 1: 'WARNING', 2: 'PASS'}
597+
out_map = {0: 'WARNING', 1: 'WARNING', 2: 'PASS'} # 0: FAIL -> WARNING Aug 2022
598598
passed = np.abs(offset) <= np.sort(np.array(tolerance))
599599
return out_map[sum(passed)], int(offset)
600600

ibllib/qc/dlc.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ class DlcQC(base.QC):
4646
'body': ['_ibl_bodyCamera.dlc.*', '_ibl_bodyCamera.times.*'],
4747
}
4848

49-
def __init__(self, session_path_or_eid, side, **kwargs):
49+
def __init__(self, session_path_or_eid, side, ignore_checks=['check_pupil_diameter_snr'], **kwargs):
5050
"""
5151
:param session_path_or_eid: A session eid or path
5252
:param side: The camera to run QC on
53+
:param ignore_checks: Checks that won't count towards aggregate QC, but will be run and added to extended QC
5354
:param log: A logging.Logger instance, if None the 'ibllib' logger is used
5455
:param one: An ONE instance for fetching and setting the QC on Alyx
5556
"""
@@ -61,6 +62,8 @@ def __init__(self, session_path_or_eid, side, **kwargs):
6162
super().__init__(session_path_or_eid, **kwargs)
6263
self.data = Bunch()
6364

65+
# checks to be added to extended QC but not taken into account for aggregate QC
66+
self.ignore_checks = ignore_checks
6467
# QC outcomes map
6568
self.metrics = None
6669

@@ -139,9 +142,9 @@ def is_metric(x):
139142
checks = getmembers(DlcQC, is_metric)
140143
self.metrics = {f'_{namespace}_' + k[6:]: fn(self) for k, fn in checks}
141144

142-
values = [x if isinstance(x, str) else x[0] for x in self.metrics.values()]
143-
code = max(base.CRITERIA[x] for x in values)
144-
outcome = next(k for k, v in base.CRITERIA.items() if v == code)
145+
ignore_metrics = [f'_{namespace}_' + i[6:] for i in self.ignore_checks]
146+
metrics_to_aggregate = {k: v for k, v in self.metrics.items() if k not in ignore_metrics}
147+
outcome = self.overall_outcome(metrics_to_aggregate.values())
145148

146149
if update:
147150
extended = {

ibllib/qc/task_metrics.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,23 @@
6161

6262
class TaskQC(base.QC):
6363
"""A class for computing task QC metrics"""
64-
criteria = {"PASS": 0.99, "WARNING": 0.95, "FAIL": 0}
65-
fcns_value2status = {'default': lambda x: TaskQC._thresholding(x),
66-
'_task_stimFreeze_delays': lambda x: - 1,
67-
'_task_response_stimFreeze_delays': lambda x: -1,
68-
'_task_passed_trial_checks': lambda x: -1,
69-
'_task_iti_delays': lambda x: -1}
64+
65+
criteria = dict()
66+
criteria['default'] = {"PASS": 0.99, "WARNING": 0.90, "FAIL": 0} # Note: WARNING was 0.95 prior to Aug 2022
67+
criteria['_task_stimOff_itiIn_delays'] = {"PASS": 0.99, "WARNING": 0}
68+
criteria['_task_positive_feedback_stimOff_delays'] = {"PASS": 0.99, "WARNING": 0}
69+
criteria['_task_negative_feedback_stimOff_delays'] = {"PASS": 0.99, "WARNING": 0}
70+
criteria['_task_wheel_move_during_closed_loop'] = {"PASS": 0.99, "WARNING": 0}
71+
criteria['_task_response_stimFreeze_delays'] = {"PASS": 0.99, "WARNING": 0}
72+
criteria['_task_detected_wheel_moves'] = {"PASS": 0.99, "WARNING": 0}
73+
criteria['_task_trial_length'] = {"PASS": 0.99, "WARNING": 0}
74+
criteria['_task_goCue_delays'] = {"PASS": 0.99, "WARNING": 0}
75+
criteria['_task_errorCue_delays'] = {"PASS": 0.99, "WARNING": 0}
76+
criteria['_task_stimOn_delays'] = {"PASS": 0.99, "WARNING": 0}
77+
criteria['_task_stimOff_delays'] = {"PASS": 0.99, "WARNING": 0}
78+
criteria['_task_stimFreeze_delays'] = {"PASS": 0.99, "WARNING": 0}
79+
criteria['_task_iti_delays'] = {"NOT_SET": 0}
80+
criteria['_task_passed_trial_checks'] = {"NOT_SET": 0}
7081

7182
@staticmethod
7283
def _thresholding(qc_value, thresholds=None):
@@ -75,18 +86,25 @@ def _thresholding(qc_value, thresholds=None):
7586
:param qc_value: proportion of passing qcs, between 0 and 1
7687
:param thresholds: dictionary with keys 'PASS', 'WARNING', 'FAIL'
7788
(cf. TaskQC.criteria attribute)
78-
:return: int where -1: NOT_SET, 0: FAIL, 1: WARNING, 2: PASS
89+
:return: int where -1: NOT_SET, 0: PASS, 1: WARNING, 2: FAIL
7990
"""
8091
MAX_BOUND, MIN_BOUND = (1, 0)
8192
if not thresholds:
82-
thresholds = TaskQC.criteria.copy()
93+
thresholds = TaskQC.criteria['default'].copy()
8394
if qc_value is None or np.isnan(qc_value):
8495
return int(-1)
8596
elif (qc_value > MAX_BOUND) or (qc_value < MIN_BOUND):
8697
raise ValueError("Values out of bound")
87-
else:
88-
passed = qc_value >= np.fromiter(thresholds.values(), dtype=float)
89-
return int(np.argmax(passed))
98+
if 'PASS' in thresholds.keys() and qc_value >= thresholds['PASS']:
99+
return 0
100+
if 'WARNING' in thresholds.keys() and qc_value >= thresholds['WARNING']:
101+
return 1
102+
if 'FAIL' in thresholds and qc_value >= thresholds['FAIL']:
103+
return 2
104+
if 'NOT_SET' in thresholds and qc_value >= thresholds['NOT_SET']:
105+
return -1
106+
# if None of this applies, return 'NOT_SET'
107+
return -1
90108

91109
def __init__(self, session_path_or_eid, **kwargs):
92110
"""
@@ -163,16 +181,15 @@ def compute_session_status_from_dict(results):
163181
:return: Overall session QC outcome as a string
164182
:return: A dict of QC tests and their outcomes
165183
"""
166-
v2status_fcns = TaskQC.fcns_value2status # the need to have this as a parameter may arise
167184
indices = np.zeros(len(results), dtype=int)
168185
for i, k in enumerate(results):
169-
if k in v2status_fcns:
170-
indices[i] = v2status_fcns[k](results[k])
186+
if k in TaskQC.criteria.keys():
187+
indices[i] = TaskQC._thresholding(results[k], thresholds=TaskQC.criteria[k])
171188
else:
172-
indices[i] = v2status_fcns['default'](results[k])
189+
indices[i] = TaskQC._thresholding(results[k], thresholds=TaskQC.criteria['default'])
173190

174191
def key_map(x):
175-
return 'NOT_SET' if x < 0 else list(TaskQC.criteria.keys())[x]
192+
return 'NOT_SET' if x < 0 else list(TaskQC.criteria['default'].keys())[x]
176193
# Criteria map is in order of severity so the max index is our overall QC outcome
177194
session_outcome = key_map(max(indices))
178195
outcomes = dict(zip(results.keys(), map(key_map, indices)))

ibllib/tests/qc/test_task_metrics.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,38 @@
1313

1414

1515
class TestAggregateOutcome(unittest.TestCase):
16-
def test_outcome_from_dict(self):
16+
def test_outcome_from_dict_default(self):
17+
# For a task that has no costume thresholds, default is 0.99 PASS and 0.9 WARNING and 0 FAIL,
18+
# np.nan and None return not set
19+
qc_dict = {'gnap': .99, 'gnop': np.nan, 'gnip': None, 'gnep': 0.9, 'gnup': 0.89}
20+
expect = {'gnap': 'PASS', 'gnop': 'NOT_SET', 'gnip': 'NOT_SET', 'gnep': 'WARNING', 'gnup': 'FAIL'}
21+
outcome, outcome_dict = qcmetrics.TaskQC.compute_session_status_from_dict(qc_dict)
22+
self.assertEqual(outcome, 'FAIL')
23+
self.assertEqual(expect, outcome_dict)
24+
25+
def test_outcome_from_dict_stimFreeze_delays(self):
26+
# For '_task_stimFreeze_delays' the threshold are 0.99 PASS and 0 WARNING
1727
qc_dict = {'gnap': .99, 'gnop': np.nan, '_task_stimFreeze_delays': .1}
18-
expect = {'gnap': 'PASS', 'gnop': 'NOT_SET', '_task_stimFreeze_delays': 'NOT_SET'}
28+
expect = {'gnap': 'PASS', 'gnop': 'NOT_SET', '_task_stimFreeze_delays': 'WARNING'}
29+
outcome, outcome_dict = qcmetrics.TaskQC.compute_session_status_from_dict(qc_dict)
30+
self.assertEqual(outcome, 'WARNING')
31+
self.assertEqual(expect, outcome_dict)
32+
33+
def test_outcome_from_dict_iti_delays(self):
34+
# For '_task_iti_delays' the threshold is 0 NOT_SET
35+
qc_dict = {'gnap': .99, 'gnop': np.nan, '_task_iti_delays': .1}
36+
expect = {'gnap': 'PASS', 'gnop': 'NOT_SET', '_task_iti_delays': 'NOT_SET'}
1937
outcome, outcome_dict = qcmetrics.TaskQC.compute_session_status_from_dict(qc_dict)
2038
self.assertEqual(outcome, 'PASS')
2139
self.assertEqual(expect, outcome_dict)
2240

41+
def test_out_of_bounds(self):
42+
# When qc values are below 0 or above 1, give error
43+
qc_dict = {'gnap': 1.01, 'gnop': 0, 'gnip': 0.99}
44+
with self.assertRaises(ValueError) as e:
45+
outcome, outcome_dict = qcmetrics.TaskQC.compute_session_status_from_dict(qc_dict)
46+
self.assertTrue(e.exception.args[0] == 'Values out of bound')
47+
2348

2449
class TestTaskMetrics(unittest.TestCase):
2550
def setUp(self):

0 commit comments

Comments
 (0)