Coverage for tasks/kirby_mcq.py: 35%
173 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/kirby.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28"""
30import logging
31import math
32from typing import Dict, List, Optional, Type
34import numpy as np
35from numpy.linalg.linalg import LinAlgError
36from scipy.stats.mstats import gmean
37from sqlalchemy.sql.schema import Column
38from sqlalchemy.sql.sqltypes import Float, Integer
39import statsmodels.api as sm
41# noinspection PyProtectedMember
42from statsmodels.discrete.discrete_model import BinaryResultsWrapper
43from statsmodels.tools.sm_exceptions import PerfectSeparationError
45from camcops_server.cc_modules.cc_constants import CssClass
46from camcops_server.cc_modules.cc_db import (
47 ancillary_relationship,
48 GenericTabletRecordMixin,
49 TaskDescendant,
50)
51from camcops_server.cc_modules.cc_html import answer, tr_qa
52from camcops_server.cc_modules.cc_request import CamcopsRequest
53from camcops_server.cc_modules.cc_sqlalchemy import Base
54from camcops_server.cc_modules.cc_sqla_coltypes import (
55 BoolColumn,
56 CurrencyColType,
57)
58from camcops_server.cc_modules.cc_summaryelement import SummaryElement
59from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
61log = logging.getLogger(__name__)
64# =============================================================================
65# KirbyRewardPair
66# =============================================================================
69class KirbyRewardPair(object):
70 """
71 Represents a pair of rewards: a small immediate reward (SIR) and a large
72 delayed reward (LDR).
73 """
75 def __init__(
76 self,
77 sir: int,
78 ldr: int,
79 delay_days: int,
80 chose_ldr: bool = None,
81 currency: str = "£",
82 currency_symbol_first: bool = True,
83 ) -> None:
84 """
85 Args:
86 sir: amount of the small immediate reward (SIR)
87 ldr: amount of the large delayed reward (LDR)
88 delay_days: delay to the LDR, in days
89 chose_ldr: if result also represented, did the subject choose the
90 LDR?
91 currency: currency symbol
92 currency_symbol_first: symbol before amount?
93 """
94 self.sir = sir
95 self.ldr = ldr
96 self.delay_days = delay_days
97 self.chose_ldr = chose_ldr
98 self.currency = currency
99 self.currency_symbol_first = currency_symbol_first
101 def money(self, amount: int) -> str:
102 """
103 Returns a currency amount, formatted.
104 """
105 if self.currency_symbol_first:
106 return f"{self.currency}{amount}"
107 return f"{amount}{self.currency}"
109 def sir_string(self, req: CamcopsRequest) -> str:
110 """
111 Returns a string representing the small immediate reward, e.g.
112 "£10 today".
113 """
114 _ = req.gettext
115 return _("{money} today").format(money=self.money(self.sir))
117 def ldr_string(self, req: CamcopsRequest) -> str:
118 """
119 Returns a string representing the large delayed reward, e.g.
120 "£50 in 200 days".
121 """
122 _ = req.gettext
123 return _("{money} in {days} days").format(
124 money=self.money(self.ldr), days=self.delay_days
125 )
127 def question(self, req: CamcopsRequest) -> str:
128 """
129 The question posed for this reward pair.
130 """
131 _ = req.gettext
132 return _("Would you prefer {sir}, or {ldr}?").format(
133 sir=self.sir_string(req), ldr=self.ldr_string(req)
134 )
136 def answer(self, req: CamcopsRequest) -> str:
137 """
138 Returns the subject's answer, or "?".
139 """
140 if self.chose_ldr is None:
141 return "?"
142 return self.ldr_string(req) if self.chose_ldr else self.sir_string(req)
144 def k_indifference(self) -> float:
145 """
146 Returns the value of k, the discounting parameter (units: days ^ -1)
147 if the subject is indifferent between the two choices.
149 For calculations see :ref:`kirby_mcq.rst <kirby_mcq>`.
150 """
151 a1 = self.sir
152 a2 = self.ldr
153 d2 = self.delay_days
154 return (a2 - a1) / (a1 * d2)
156 def choice_consistent(self, k: float) -> bool:
157 """
158 Was the choice consistent with the k value given?
160 - If no choice has been recorded, returns false.
162 - If the k value equals the implied indifference point exactly (meaning
163 that the subject should not care), return true.
164 """
165 if self.chose_ldr is None:
166 return False
167 k_indiff = self.k_indifference()
168 if math.isclose(k, k_indiff):
169 # Subject is indifferent
170 return True
171 # WARNING: "self.chose_ldr == k < k_indiff" FAILS.
172 # Python will evaluate this to "(self.chose_ldr == k) < k_indiff", and
173 # despite that evaluating to "a bool < an int", that's legal; e.g.
174 # "False < 4" evaluates to True.
175 # Must be bracketed like this:
176 return self.chose_ldr == (k < k_indiff)
179# =============================================================================
180# KirbyTrial
181# =============================================================================
184class KirbyTrial(GenericTabletRecordMixin, TaskDescendant, Base):
185 __tablename__ = "kirby_mcq_trials"
187 kirby_mcq_id = Column(
188 "kirby_mcq_id", Integer, nullable=False, comment="FK to kirby_mcq"
189 )
190 trial = Column(
191 "trial", Integer, nullable=False, comment="Trial number (1-based)"
192 )
193 sir = Column("sir", Integer, comment="Small immediate reward")
194 ldr = Column("ldr", Integer, comment="Large delayed reward")
195 delay_days = Column("delay_days", Integer, comment="Delay in days")
196 currency = Column("currency", CurrencyColType, comment="Currency symbol")
197 currency_symbol_first = BoolColumn(
198 "currency_symbol_first",
199 comment="Does the currency symbol come before the amount?",
200 )
201 chose_ldr = BoolColumn(
202 "chose_ldr", comment="Did the subject choose the large delayed reward?"
203 )
205 def info(self) -> KirbyRewardPair:
206 """
207 Returns the trial information as a :class:`KirbyRewardPair`.
208 """
209 return KirbyRewardPair(
210 sir=self.sir,
211 ldr=self.ldr,
212 delay_days=self.delay_days,
213 chose_ldr=self.chose_ldr,
214 currency=self.currency,
215 currency_symbol_first=self.currency_symbol_first,
216 )
218 def answered(self) -> bool:
219 """
220 Has the subject answered this question?
221 """
222 return self.chose_ldr is not None
224 # -------------------------------------------------------------------------
225 # TaskDescendant overrides
226 # -------------------------------------------------------------------------
228 @classmethod
229 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
230 return Kirby
232 def task_ancestor(self) -> Optional["Kirby"]:
233 return Kirby.get_linked(self.kirby_mcq_id, self)
236# =============================================================================
237# Kirby
238# =============================================================================
241class Kirby(TaskHasPatientMixin, Task):
242 """
243 Server implementation of the Kirby Monetary Choice Questionnaire task.
244 """
246 __tablename__ = "kirby_mcq"
247 shortname = "KirbyMCQ"
249 EXPECTED_N_TRIALS = 27
251 # No fields beyond the basics.
253 # Relationships
254 trials = ancillary_relationship(
255 parent_class_name="Kirby",
256 ancillary_class_name="KirbyTrial",
257 ancillary_fk_to_parent_attr_name="kirby_mcq_id",
258 ancillary_order_by_attr_name="trial",
259 ) # type: List[KirbyTrial]
261 @staticmethod
262 def longname(req: "CamcopsRequest") -> str:
263 _ = req.gettext
264 return _("Kirby et al. 1999 Monetary Choice Questionnaire")
266 def is_complete(self) -> bool:
267 if len(self.trials) != self.EXPECTED_N_TRIALS:
268 return False
269 for t in self.trials:
270 if not t.answered():
271 return False
272 return True
274 def all_choice_results(self) -> List[KirbyRewardPair]:
275 """
276 Returns a list of :class:`KirbyRewardPair` objects, one for each
277 answered question.
278 """
279 results = [] # type: List[KirbyRewardPair]
280 for t in self.trials:
281 if t.answered():
282 results.append(t.info())
283 return results
285 @staticmethod
286 def n_choices_consistent(k: float, results: List[KirbyRewardPair]) -> int:
287 """
288 Returns the number of choices that are consistent with the given k
289 value.
290 """
291 n_consistent = 0
292 for pair in results:
293 if pair.choice_consistent(k):
294 n_consistent += 1
295 return n_consistent
297 def k_kirby(self, results: List[KirbyRewardPair]) -> Optional[float]:
298 """
299 Returns k for a subject as determined using Kirby's (2000) method.
300 See :ref:`kirby_mcq.rst <kirby_mcq>`.
301 """
302 # 1. For every k value assessed by the questions, establish the degree
303 # of consistency.
304 consistency = {} # type: Dict[float, int]
305 for pair in results:
306 k = pair.k_indifference()
307 if k not in consistency:
308 consistency[k] = self.n_choices_consistent(k, results)
309 if not consistency:
310 return None
312 # 2. Restrict to the results that are equally and maximally consistent.
313 max_consistency = max(consistency.values())
314 good_k_values = [
315 k for k, v in consistency.items() if v == max_consistency
316 ]
318 # 3. Take the geometric mean of those good k values.
319 # noinspection PyTypeChecker
320 subject_k = gmean(good_k_values) # type: np.float64
322 return float(subject_k)
324 @staticmethod
325 def k_wileyto(results: List[KirbyRewardPair]) -> Optional[float]:
326 """
327 Returns k for a subject as determined using Wileyto et al.'s (2004)
328 method. See :ref:`kirby_mcq.rst <kirby_mcq>`.
329 """
330 if not results:
331 return None
332 n_predictors = 2
333 n_observations = len(results)
334 x = np.zeros((n_observations, n_predictors))
335 y = np.zeros(n_observations)
336 for i in range(n_observations):
337 pair = results[i]
338 a1 = pair.sir
339 a2 = pair.ldr
340 d2 = pair.delay_days
341 predictor1 = 1 - (a2 / a1)
342 predictor2 = d2
343 x[i, 0] = predictor1
344 x[i, 1] = predictor2
345 y[i] = int(pair.chose_ldr) # bool to int
346 lr = sm.Logit(y, x)
347 try:
348 result = lr.fit() # type: BinaryResultsWrapper
349 except (
350 LinAlgError, # e.g. "singular matrix"
351 PerfectSeparationError,
352 ) as e:
353 log.debug(f"sm.Logit error: {e}")
354 return None
355 coeffs = result.params
356 beta1 = coeffs[0]
357 beta2 = coeffs[1]
358 try:
359 k = beta2 / beta1
360 except ZeroDivisionError:
361 log.warning("Division by zero when calculating k = beta2 / beta1")
362 return None
363 return k
365 # noinspection PyUnusedLocal
366 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
367 results = self.all_choice_results()
368 return self.standard_task_summary_fields() + [
369 SummaryElement(
370 name="k_kirby",
371 coltype=Float(),
372 value=self.k_kirby(results),
373 comment="k (days^-1, Kirby 2000 method)",
374 ),
375 SummaryElement(
376 name="k_wileyto",
377 coltype=Float(),
378 value=self.k_wileyto(results),
379 comment="k (days^-1, Wileyto 2004 method)",
380 ),
381 ]
383 def get_task_html(self, req: CamcopsRequest) -> str:
384 dp = 6
385 qlines = [] # type: List[str]
386 for t in self.trials:
387 info = t.info()
388 qlines.append(
389 tr_qa(
390 f"{t.trial}. {info.question(req)} "
391 f"<i>(k<sub>indiff</sub> = "
392 f"{round(info.k_indifference(), dp)})</i>",
393 info.answer(req),
394 )
395 )
396 q_a = "\n".join(qlines)
397 results = self.all_choice_results()
398 k_kirby = self.k_kirby(results)
399 if k_kirby is None:
400 inv_k_kirby = None
401 else:
402 inv_k_kirby = int(round(1 / k_kirby)) # round to int
403 # ... you'd think the int() was unnecessary but it is needed
404 k_kirby = round(k_kirby, dp)
405 k_wileyto = self.k_wileyto(results)
406 if k_wileyto is None:
407 inv_k_wileyto = None
408 else:
409 inv_k_wileyto = int(round(1 / k_wileyto)) # round to int
410 k_wileyto = round(k_wileyto, dp)
411 return f"""
412 <div class="{CssClass.SUMMARY}">
413 <table class="{CssClass.SUMMARY}">
414 {self.get_is_complete_tr(req)}
415 <tr>
416 <td><i>k</i> (days<sup>–1</sup>, Kirby 2000 method)</td>
417 <td>{answer(k_kirby)}
418 </tr>
419 <tr>
420 <td>1/<i>k</i> (days, Kirby method): time to half value</td>
421 <td>{answer(inv_k_kirby)}
422 </tr>
423 <tr>
424 <td><i>k</i> (days<sup>–1</sup>, Wileyto et al. 2004 method)</td>
425 <td>{answer(k_wileyto)}
426 </tr>
427 <tr>
428 <td>1/<i>k</i> (days, Wileyto method): time to half value</td>
429 <td>{answer(inv_k_wileyto)}
430 </tr>
431 </table>
432 </div>
433 <table class="{CssClass.TASKDETAIL}">
434 <tr>
435 <th width="75%">Question</th>
436 <th width="25%">Answer</th>
437 </tr>
438 {q_a}
439 </table>
440 """ # noqa