Coverage for /usr/lib/python3/dist-packages/scipy/stats/_odds_ratio.py: 15%

137 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1import numpy as np 

2 

3from scipy.special import ndtri 

4from scipy.optimize import brentq 

5from ._discrete_distns import nchypergeom_fisher 

6from ._common import ConfidenceInterval 

7 

8 

9def _sample_odds_ratio(table): 

10 """ 

11 Given a table [[a, b], [c, d]], compute a*d/(b*c). 

12 

13 Return nan if the numerator and denominator are 0. 

14 Return inf if just the denominator is 0. 

15 """ 

16 # table must be a 2x2 numpy array. 

17 if table[1, 0] > 0 and table[0, 1] > 0: 

18 oddsratio = table[0, 0] * table[1, 1] / (table[1, 0] * table[0, 1]) 

19 elif table[0, 0] == 0 or table[1, 1] == 0: 

20 oddsratio = np.nan 

21 else: 

22 oddsratio = np.inf 

23 return oddsratio 

24 

25 

26def _solve(func): 

27 """ 

28 Solve func(nc) = 0. func must be an increasing function. 

29 """ 

30 # We could just as well call the variable `x` instead of `nc`, but we 

31 # always call this function with functions for which nc (the noncentrality 

32 # parameter) is the variable for which we are solving. 

33 nc = 1.0 

34 value = func(nc) 

35 if value == 0: 

36 return nc 

37 

38 # Multiplicative factor by which to increase or decrease nc when 

39 # searching for a bracketing interval. 

40 factor = 2.0 

41 # Find a bracketing interval. 

42 if value > 0: 

43 nc /= factor 

44 while func(nc) > 0: 

45 nc /= factor 

46 lo = nc 

47 hi = factor*nc 

48 else: 

49 nc *= factor 

50 while func(nc) < 0: 

51 nc *= factor 

52 lo = nc/factor 

53 hi = nc 

54 

55 # lo and hi bracket the solution for nc. 

56 nc = brentq(func, lo, hi, xtol=1e-13) 

57 return nc 

58 

59 

60def _nc_hypergeom_mean_inverse(x, M, n, N): 

61 """ 

62 For the given noncentral hypergeometric parameters x, M, n,and N 

63 (table[0,0], total, row 0 sum and column 0 sum, resp., of a 2x2 

64 contingency table), find the noncentrality parameter of Fisher's 

65 noncentral hypergeometric distribution whose mean is x. 

66 """ 

67 nc = _solve(lambda nc: nchypergeom_fisher.mean(M, n, N, nc) - x) 

68 return nc 

69 

70 

71def _hypergeom_params_from_table(table): 

72 # The notation M, n and N is consistent with stats.hypergeom and 

73 # stats.nchypergeom_fisher. 

74 x = table[0, 0] 

75 M = table.sum() 

76 n = table[0].sum() 

77 N = table[:, 0].sum() 

78 return x, M, n, N 

79 

80 

81def _ci_upper(table, alpha): 

82 """ 

83 Compute the upper end of the confidence interval. 

84 """ 

85 if _sample_odds_ratio(table) == np.inf: 

86 return np.inf 

87 

88 x, M, n, N = _hypergeom_params_from_table(table) 

89 

90 # nchypergeom_fisher.cdf is a decreasing function of nc, so we negate 

91 # it in the lambda expression. 

92 nc = _solve(lambda nc: -nchypergeom_fisher.cdf(x, M, n, N, nc) + alpha) 

93 return nc 

94 

95 

96def _ci_lower(table, alpha): 

97 """ 

98 Compute the lower end of the confidence interval. 

99 """ 

100 if _sample_odds_ratio(table) == 0: 

101 return 0 

102 

103 x, M, n, N = _hypergeom_params_from_table(table) 

104 

105 nc = _solve(lambda nc: nchypergeom_fisher.sf(x - 1, M, n, N, nc) - alpha) 

106 return nc 

107 

108 

109def _conditional_oddsratio(table): 

110 """ 

111 Conditional MLE of the odds ratio for the 2x2 contingency table. 

112 """ 

113 x, M, n, N = _hypergeom_params_from_table(table) 

114 # Get the bounds of the support. The support of the noncentral 

115 # hypergeometric distribution with parameters M, n, and N is the same 

116 # for all values of the noncentrality parameter, so we can use 1 here. 

117 lo, hi = nchypergeom_fisher.support(M, n, N, 1) 

118 

119 # Check if x is at one of the extremes of the support. If so, we know 

120 # the odds ratio is either 0 or inf. 

121 if x == lo: 

122 # x is at the low end of the support. 

123 return 0 

124 if x == hi: 

125 # x is at the high end of the support. 

126 return np.inf 

127 

128 nc = _nc_hypergeom_mean_inverse(x, M, n, N) 

129 return nc 

130 

131 

132def _conditional_oddsratio_ci(table, confidence_level=0.95, 

133 alternative='two-sided'): 

134 """ 

135 Conditional exact confidence interval for the odds ratio. 

136 """ 

137 if alternative == 'two-sided': 

138 alpha = 0.5*(1 - confidence_level) 

139 lower = _ci_lower(table, alpha) 

140 upper = _ci_upper(table, alpha) 

141 elif alternative == 'less': 

142 lower = 0.0 

143 upper = _ci_upper(table, 1 - confidence_level) 

144 else: 

145 # alternative == 'greater' 

146 lower = _ci_lower(table, 1 - confidence_level) 

147 upper = np.inf 

148 

149 return lower, upper 

150 

151 

152def _sample_odds_ratio_ci(table, confidence_level=0.95, 

153 alternative='two-sided'): 

154 oddsratio = _sample_odds_ratio(table) 

155 log_or = np.log(oddsratio) 

156 se = np.sqrt((1/table).sum()) 

157 if alternative == 'less': 

158 z = ndtri(confidence_level) 

159 loglow = -np.inf 

160 loghigh = log_or + z*se 

161 elif alternative == 'greater': 

162 z = ndtri(confidence_level) 

163 loglow = log_or - z*se 

164 loghigh = np.inf 

165 else: 

166 # alternative is 'two-sided' 

167 z = ndtri(0.5*confidence_level + 0.5) 

168 loglow = log_or - z*se 

169 loghigh = log_or + z*se 

170 

171 return np.exp(loglow), np.exp(loghigh) 

172 

173 

174class OddsRatioResult: 

175 """ 

176 Result of `scipy.stats.contingency.odds_ratio`. See the 

177 docstring for `odds_ratio` for more details. 

178 

179 Attributes 

180 ---------- 

181 statistic : float 

182 The computed odds ratio. 

183 

184 * If `kind` is ``'sample'``, this is sample (or unconditional) 

185 estimate, given by 

186 ``table[0, 0]*table[1, 1]/(table[0, 1]*table[1, 0])``. 

187 * If `kind` is ``'conditional'``, this is the conditional 

188 maximum likelihood estimate for the odds ratio. It is 

189 the noncentrality parameter of Fisher's noncentral 

190 hypergeometric distribution with the same hypergeometric 

191 parameters as `table` and whose mean is ``table[0, 0]``. 

192 

193 Methods 

194 ------- 

195 confidence_interval : 

196 Confidence interval for the odds ratio. 

197 """ 

198 

199 def __init__(self, _table, _kind, statistic): 

200 # for now, no need to make _table and _kind public, since this sort of 

201 # information is returned in very few `scipy.stats` results 

202 self._table = _table 

203 self._kind = _kind 

204 self.statistic = statistic 

205 

206 def __repr__(self): 

207 return f"OddsRatioResult(statistic={self.statistic})" 

208 

209 def confidence_interval(self, confidence_level=0.95, 

210 alternative='two-sided'): 

211 """ 

212 Confidence interval for the odds ratio. 

213 

214 Parameters 

215 ---------- 

216 confidence_level: float 

217 Desired confidence level for the confidence interval. 

218 The value must be given as a fraction between 0 and 1. 

219 Default is 0.95 (meaning 95%). 

220 

221 alternative : {'two-sided', 'less', 'greater'}, optional 

222 The alternative hypothesis of the hypothesis test to which the 

223 confidence interval corresponds. That is, suppose the null 

224 hypothesis is that the true odds ratio equals ``OR`` and the 

225 confidence interval is ``(low, high)``. Then the following options 

226 for `alternative` are available (default is 'two-sided'): 

227 

228 * 'two-sided': the true odds ratio is not equal to ``OR``. There 

229 is evidence against the null hypothesis at the chosen 

230 `confidence_level` if ``high < OR`` or ``low > OR``. 

231 * 'less': the true odds ratio is less than ``OR``. The ``low`` end 

232 of the confidence interval is 0, and there is evidence against 

233 the null hypothesis at the chosen `confidence_level` if 

234 ``high < OR``. 

235 * 'greater': the true odds ratio is greater than ``OR``. The 

236 ``high`` end of the confidence interval is ``np.inf``, and there 

237 is evidence against the null hypothesis at the chosen 

238 `confidence_level` if ``low > OR``. 

239 

240 Returns 

241 ------- 

242 ci : ``ConfidenceInterval`` instance 

243 The confidence interval, represented as an object with 

244 attributes ``low`` and ``high``. 

245 

246 Notes 

247 ----- 

248 When `kind` is ``'conditional'``, the limits of the confidence 

249 interval are the conditional "exact confidence limits" as described 

250 by Fisher [1]_. The conditional odds ratio and confidence interval are 

251 also discussed in Section 4.1.2 of the text by Sahai and Khurshid [2]_. 

252 

253 When `kind` is ``'sample'``, the confidence interval is computed 

254 under the assumption that the logarithm of the odds ratio is normally 

255 distributed with standard error given by:: 

256 

257 se = sqrt(1/a + 1/b + 1/c + 1/d) 

258 

259 where ``a``, ``b``, ``c`` and ``d`` are the elements of the 

260 contingency table. (See, for example, [2]_, section 3.1.3.2, 

261 or [3]_, section 2.3.3). 

262 

263 References 

264 ---------- 

265 .. [1] R. A. Fisher (1935), The logic of inductive inference, 

266 Journal of the Royal Statistical Society, Vol. 98, No. 1, 

267 pp. 39-82. 

268 .. [2] H. Sahai and A. Khurshid (1996), Statistics in Epidemiology: 

269 Methods, Techniques, and Applications, CRC Press LLC, Boca 

270 Raton, Florida. 

271 .. [3] Alan Agresti, An Introduction to Categorical Data Analyis 

272 (second edition), Wiley, Hoboken, NJ, USA (2007). 

273 """ 

274 if alternative not in ['two-sided', 'less', 'greater']: 

275 raise ValueError("`alternative` must be 'two-sided', 'less' or " 

276 "'greater'.") 

277 

278 if confidence_level < 0 or confidence_level > 1: 

279 raise ValueError('confidence_level must be between 0 and 1') 

280 

281 if self._kind == 'conditional': 

282 ci = self._conditional_odds_ratio_ci(confidence_level, alternative) 

283 else: 

284 ci = self._sample_odds_ratio_ci(confidence_level, alternative) 

285 return ci 

286 

287 def _conditional_odds_ratio_ci(self, confidence_level=0.95, 

288 alternative='two-sided'): 

289 """ 

290 Confidence interval for the conditional odds ratio. 

291 """ 

292 

293 table = self._table 

294 if 0 in table.sum(axis=0) or 0 in table.sum(axis=1): 

295 # If both values in a row or column are zero, the p-value is 1, 

296 # the odds ratio is NaN and the confidence interval is (0, inf). 

297 ci = (0, np.inf) 

298 else: 

299 ci = _conditional_oddsratio_ci(table, 

300 confidence_level=confidence_level, 

301 alternative=alternative) 

302 return ConfidenceInterval(low=ci[0], high=ci[1]) 

303 

304 def _sample_odds_ratio_ci(self, confidence_level=0.95, 

305 alternative='two-sided'): 

306 """ 

307 Confidence interval for the sample odds ratio. 

308 """ 

309 if confidence_level < 0 or confidence_level > 1: 

310 raise ValueError('confidence_level must be between 0 and 1') 

311 

312 table = self._table 

313 if 0 in table.sum(axis=0) or 0 in table.sum(axis=1): 

314 # If both values in a row or column are zero, the p-value is 1, 

315 # the odds ratio is NaN and the confidence interval is (0, inf). 

316 ci = (0, np.inf) 

317 else: 

318 ci = _sample_odds_ratio_ci(table, 

319 confidence_level=confidence_level, 

320 alternative=alternative) 

321 return ConfidenceInterval(low=ci[0], high=ci[1]) 

322 

323 

324def odds_ratio(table, *, kind='conditional'): 

325 r""" 

326 Compute the odds ratio for a 2x2 contingency table. 

327 

328 Parameters 

329 ---------- 

330 table : array_like of ints 

331 A 2x2 contingency table. Elements must be non-negative integers. 

332 kind : str, optional 

333 Which kind of odds ratio to compute, either the sample 

334 odds ratio (``kind='sample'``) or the conditional odds ratio 

335 (``kind='conditional'``). Default is ``'conditional'``. 

336 

337 Returns 

338 ------- 

339 result : `~scipy.stats._result_classes.OddsRatioResult` instance 

340 The returned object has two computed attributes: 

341 

342 statistic : float 

343 * If `kind` is ``'sample'``, this is sample (or unconditional) 

344 estimate, given by 

345 ``table[0, 0]*table[1, 1]/(table[0, 1]*table[1, 0])``. 

346 * If `kind` is ``'conditional'``, this is the conditional 

347 maximum likelihood estimate for the odds ratio. It is 

348 the noncentrality parameter of Fisher's noncentral 

349 hypergeometric distribution with the same hypergeometric 

350 parameters as `table` and whose mean is ``table[0, 0]``. 

351 

352 The object has the method `confidence_interval` that computes 

353 the confidence interval of the odds ratio. 

354 

355 See Also 

356 -------- 

357 scipy.stats.fisher_exact 

358 relative_risk 

359 

360 Notes 

361 ----- 

362 The conditional odds ratio was discussed by Fisher (see "Example 1" 

363 of [1]_). Texts that cover the odds ratio include [2]_ and [3]_. 

364 

365 .. versionadded:: 1.10.0 

366 

367 References 

368 ---------- 

369 .. [1] R. A. Fisher (1935), The logic of inductive inference, 

370 Journal of the Royal Statistical Society, Vol. 98, No. 1, 

371 pp. 39-82. 

372 .. [2] Breslow NE, Day NE (1980). Statistical methods in cancer research. 

373 Volume I - The analysis of case-control studies. IARC Sci Publ. 

374 (32):5-338. PMID: 7216345. (See section 4.2.) 

375 .. [3] H. Sahai and A. Khurshid (1996), Statistics in Epidemiology: 

376 Methods, Techniques, and Applications, CRC Press LLC, Boca 

377 Raton, Florida. 

378 .. [4] Berger, Jeffrey S. et al. "Aspirin for the Primary Prevention of 

379 Cardiovascular Events in Women and Men: A Sex-Specific 

380 Meta-analysis of Randomized Controlled Trials." 

381 JAMA, 295(3):306-313, :doi:`10.1001/jama.295.3.306`, 2006. 

382 

383 Examples 

384 -------- 

385 In epidemiology, individuals are classified as "exposed" or 

386 "unexposed" to some factor or treatment. If the occurrence of some 

387 illness is under study, those who have the illness are often 

388 classifed as "cases", and those without it are "noncases". The 

389 counts of the occurrences of these classes gives a contingency 

390 table:: 

391 

392 exposed unexposed 

393 cases a b 

394 noncases c d 

395 

396 The sample odds ratio may be written ``(a/c) / (b/d)``. ``a/c`` can 

397 be interpreted as the odds of a case occurring in the exposed group, 

398 and ``b/d`` as the odds of a case occurring in the unexposed group. 

399 The sample odds ratio is the ratio of these odds. If the odds ratio 

400 is greater than 1, it suggests that there is a positive association 

401 between being exposed and being a case. 

402 

403 Interchanging the rows or columns of the contingency table inverts 

404 the odds ratio, so it is import to understand the meaning of labels 

405 given to the rows and columns of the table when interpreting the 

406 odds ratio. 

407 

408 In [4]_, the use of aspirin to prevent cardiovascular events in women 

409 and men was investigated. The study notably concluded: 

410 

411 ...aspirin therapy reduced the risk of a composite of 

412 cardiovascular events due to its effect on reducing the risk of 

413 ischemic stroke in women [...] 

414 

415 The article lists studies of various cardiovascular events. Let's 

416 focus on the ischemic stoke in women. 

417 

418 The following table summarizes the results of the experiment in which 

419 participants took aspirin or a placebo on a regular basis for several 

420 years. Cases of ischemic stroke were recorded:: 

421 

422 Aspirin Control/Placebo 

423 Ischemic stroke 176 230 

424 No stroke 21035 21018 

425 

426 The question we ask is "Is there evidence that the aspirin reduces the 

427 risk of ischemic stroke?" 

428 

429 Compute the odds ratio: 

430 

431 >>> from scipy.stats.contingency import odds_ratio 

432 >>> res = odds_ratio([[176, 230], [21035, 21018]]) 

433 >>> res.statistic 

434 0.7646037659999126 

435 

436 For this sample, the odds of getting an ischemic stroke for those who have 

437 been taking aspirin are 0.76 times that of those 

438 who have received the placebo. 

439 

440 To make statistical inferences about the population under study, 

441 we can compute the 95% confidence interval for the odds ratio: 

442 

443 >>> res.confidence_interval(confidence_level=0.95) 

444 ConfidenceInterval(low=0.6241234078749812, high=0.9354102892100372) 

445 

446 The 95% confidence interval for the conditional odds ratio is 

447 approximately (0.62, 0.94). 

448 

449 The fact that the entire 95% confidence interval falls below 1 supports 

450 the authors' conclusion that the aspirin was associated with a 

451 statistically significant reduction in ischemic stroke. 

452 """ 

453 if kind not in ['conditional', 'sample']: 

454 raise ValueError("`kind` must be 'conditional' or 'sample'.") 

455 

456 c = np.asarray(table) 

457 

458 if c.shape != (2, 2): 

459 raise ValueError(f"Invalid shape {c.shape}. The input `table` must be " 

460 "of shape (2, 2).") 

461 

462 if not np.issubdtype(c.dtype, np.integer): 

463 raise ValueError("`table` must be an array of integers, but got " 

464 f"type {c.dtype}") 

465 c = c.astype(np.int64) 

466 

467 if np.any(c < 0): 

468 raise ValueError("All values in `table` must be nonnegative.") 

469 

470 if 0 in c.sum(axis=0) or 0 in c.sum(axis=1): 

471 # If both values in a row or column are zero, the p-value is NaN and 

472 # the odds ratio is NaN. 

473 result = OddsRatioResult(_table=c, _kind=kind, statistic=np.nan) 

474 return result 

475 

476 if kind == 'sample': 

477 oddsratio = _sample_odds_ratio(c) 

478 else: # kind is 'conditional' 

479 oddsratio = _conditional_oddsratio(c) 

480 

481 result = OddsRatioResult(_table=c, _kind=kind, statistic=oddsratio) 

482 return result