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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/kirby.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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/>. 

25 

26=============================================================================== 

27 

28""" 

29 

30import logging 

31import math 

32from typing import Dict, List, Optional, Type 

33 

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 

40 

41# noinspection PyProtectedMember 

42from statsmodels.discrete.discrete_model import BinaryResultsWrapper 

43from statsmodels.tools.sm_exceptions import PerfectSeparationError 

44 

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 

60 

61log = logging.getLogger(__name__) 

62 

63 

64# ============================================================================= 

65# KirbyRewardPair 

66# ============================================================================= 

67 

68 

69class KirbyRewardPair(object): 

70 """ 

71 Represents a pair of rewards: a small immediate reward (SIR) and a large 

72 delayed reward (LDR). 

73 """ 

74 

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 

100 

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}" 

108 

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)) 

116 

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 ) 

126 

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 ) 

135 

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) 

143 

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. 

148 

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) 

155 

156 def choice_consistent(self, k: float) -> bool: 

157 """ 

158 Was the choice consistent with the k value given? 

159 

160 - If no choice has been recorded, returns false. 

161 

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) 

177 

178 

179# ============================================================================= 

180# KirbyTrial 

181# ============================================================================= 

182 

183 

184class KirbyTrial(GenericTabletRecordMixin, TaskDescendant, Base): 

185 __tablename__ = "kirby_mcq_trials" 

186 

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 ) 

204 

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 ) 

217 

218 def answered(self) -> bool: 

219 """ 

220 Has the subject answered this question? 

221 """ 

222 return self.chose_ldr is not None 

223 

224 # ------------------------------------------------------------------------- 

225 # TaskDescendant overrides 

226 # ------------------------------------------------------------------------- 

227 

228 @classmethod 

229 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

230 return Kirby 

231 

232 def task_ancestor(self) -> Optional["Kirby"]: 

233 return Kirby.get_linked(self.kirby_mcq_id, self) 

234 

235 

236# ============================================================================= 

237# Kirby 

238# ============================================================================= 

239 

240 

241class Kirby(TaskHasPatientMixin, Task): 

242 """ 

243 Server implementation of the Kirby Monetary Choice Questionnaire task. 

244 """ 

245 

246 __tablename__ = "kirby_mcq" 

247 shortname = "KirbyMCQ" 

248 

249 EXPECTED_N_TRIALS = 27 

250 

251 # No fields beyond the basics. 

252 

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] 

260 

261 @staticmethod 

262 def longname(req: "CamcopsRequest") -> str: 

263 _ = req.gettext 

264 return _("Kirby et al. 1999 Monetary Choice Questionnaire") 

265 

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 

273 

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 

284 

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 

296 

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 

311 

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 ] 

317 

318 # 3. Take the geometric mean of those good k values. 

319 # noinspection PyTypeChecker 

320 subject_k = gmean(good_k_values) # type: np.float64 

321 

322 return float(subject_k) 

323 

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 

364 

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 ] 

382 

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