Coverage for HARK / distributions / continuous.py: 90%

155 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-25 05:22 +0000

1from typing import Any, Optional, Union 

2 

3import numpy as np 

4from scipy.special import erfc 

5from scipy.stats import ( 

6 rv_continuous, 

7 norm, 

8 lognorm, 

9 uniform, 

10 weibull_min, 

11) 

12from scipy.stats._distn_infrastructure import rv_continuous_frozen 

13 

14from HARK.distributions.base import Distribution 

15from HARK.distributions.discrete import DiscreteDistribution 

16 

17# CONTINUOUS DISTRIBUTIONS 

18 

19 

20class ContinuousFrozenDistribution(rv_continuous_frozen, Distribution): 

21 """ 

22 Parameterized continuous distribution from scipy.stats with seed management. 

23 """ 

24 

25 def __init__( 

26 self, dist: rv_continuous, *args: Any, seed: int = 0, **kwds: Any 

27 ) -> None: 

28 """ 

29 Parameterized continuous distribution from scipy.stats with seed management. 

30 

31 Parameters 

32 ---------- 

33 dist : rv_continuous 

34 Continuous distribution from scipy.stats. 

35 seed : int, optional 

36 Seed for random number generator, by default 0 

37 """ 

38 rv_continuous_frozen.__init__(self, dist, *args, **kwds) 

39 Distribution.__init__(self, seed=seed) 

40 

41 

42class Normal(ContinuousFrozenDistribution): 

43 """ 

44 A Normal distribution. 

45 

46 Parameters 

47 ---------- 

48 mu : float or [float] 

49 One or more means. Number of elements T in mu determines number 

50 of rows of output. 

51 sigma : float or [float] 

52 One or more standard deviations. Number of elements T in sigma 

53 determines number of rows of output. 

54 seed : int 

55 Seed for random number generator. 

56 """ 

57 

58 def __new__( 

59 cls, 

60 mu: Union[float, np.ndarray] = 0.0, 

61 sigma: Union[float, np.ndarray] = 1.0, 

62 seed: Optional[int] = None, 

63 ): 

64 """ 

65 Create a new Normal distribution. If sigma is zero, return a 

66 DiscreteDistribution with a single atom at mu. 

67 Parameters 

68 ---------- 

69 mu : Union[float, np.ndarray], optional 

70 Mean of normal distribution, by default 0.0 

71 sigma : Union[float, np.ndarray], optional 

72 Standard deviation of normal distribution, by default 1.0 

73 seed : Optional[int], optional 

74 Seed for random number generator, by default None 

75 Returns 

76 ------- 

77 Normal or DiscreteDistribution 

78 Normal distribution or DiscreteDistribution with a single atom. 

79 """ 

80 

81 if sigma == 0: 

82 # If sigma is zero, return a DiscreteDistribution with a single atom 

83 return DiscreteDistribution([1.0], mu, seed=seed) 

84 

85 return super().__new__(cls) 

86 

87 def __init__(self, mu=0.0, sigma=1.0, seed=None): 

88 self.mu = np.asarray(mu) 

89 self.sigma = np.asarray(sigma) 

90 

91 if self.mu.size != self.sigma.size: 

92 raise AttributeError( 

93 f"'mu' and 'sigma' must be of the same size. Instead 'mu' is of " 

94 f"size {self.mu.size} and 'sigma' is of size {self.sigma.size}." 

95 ) 

96 

97 super().__init__(norm, loc=mu, scale=sigma, seed=seed) 

98 self.infimum = -np.inf * np.ones(self.mu.size) 

99 self.supremum = np.inf * np.ones(self.mu.size) 

100 

101 def discretize(self, N, method="hermite", endpoints=False): 

102 """ 

103 For normal distributions, the Gauss-Hermite quadrature rule is 

104 used as the default method for discretization. 

105 """ 

106 

107 return super().discretize(N, method, endpoints) 

108 

109 def _approx_hermite(self, N, endpoints=False): 

110 """ 

111 Returns a discrete approximation of this distribution 

112 using the Gauss-Hermite quadrature rule. 

113 

114 Parameters 

115 ---------- 

116 N : int 

117 Number of discrete points to approximate the distribution. 

118 

119 Returns 

120 ------- 

121 DiscreteDistribution 

122 Discrete approximation of this distribution. 

123 """ 

124 

125 x, w = np.polynomial.hermite.hermgauss(N) 

126 # normalize w 

127 pmv = w * np.pi**-0.5 

128 # correct x 

129 atoms = np.sqrt(2.0) * self.sigma * x + self.mu 

130 

131 if endpoints: 

132 atoms = np.r_[-np.inf, atoms, np.inf] 

133 pmv = np.r_[0.0, pmv, 0.0] 

134 

135 limit = {"dist": self, "method": "hermite", "N": N, "endpoints": endpoints} 

136 

137 return DiscreteDistribution( 

138 pmv, 

139 atoms, 

140 seed=self.random_seed(), 

141 limit=limit, 

142 ) 

143 

144 def _approx_equiprobable(self, N, endpoints=False): 

145 """ 

146 Returns a discrete equiprobable approximation of this distribution. 

147 

148 Parameters 

149 ---------- 

150 N : int 

151 Number of discrete points to approximate the distribution. 

152 

153 Returns 

154 ------- 

155 DiscreteDistribution 

156 Discrete approximation of this distribution. 

157 """ 

158 

159 CDF = np.linspace(0, 1, N + 1) 

160 lims = norm.ppf(CDF) 

161 pdf = norm.pdf(lims) 

162 

163 # Find conditional means using Mills's ratio 

164 pmv = np.diff(CDF) 

165 atoms = self.mu - np.diff(pdf) / pmv * self.sigma 

166 

167 if endpoints: 

168 atoms = np.r_[-np.inf, atoms, np.inf] 

169 pmv = np.r_[0.0, pmv, 0.0] 

170 

171 limit = {"dist": self, "method": "equiprobable", "N": N, "endpoints": endpoints} 

172 

173 return DiscreteDistribution( 

174 pmv, 

175 atoms, 

176 seed=self._rng.integers(0, 2**31 - 1, dtype="int32"), 

177 limit=limit, 

178 ) 

179 

180 

181class Lognormal(ContinuousFrozenDistribution): 

182 """ 

183 A Lognormal distribution 

184 

185 Parameters 

186 ---------- 

187 mu : float or [float] 

188 One or more means of underlying normal distribution. 

189 Number of elements T in mu determines number of rows of output. 

190 sigma : float or [float] 

191 One or more standard deviations of underlying normal distribution. 

192 Number of elements T in sigma determines number of rows of output. 

193 seed : int 

194 Seed for random number generator. 

195 """ 

196 

197 def __new__( 

198 cls, 

199 mu: Union[float, np.ndarray] = 0.0, 

200 sigma: Union[float, np.ndarray] = 1.0, 

201 seed: Optional[int] = None, 

202 mean=None, 

203 std=None, 

204 ): 

205 """ 

206 Create a new Lognormal distribution. If sigma is zero, return a 

207 DiscreteDistribution with a single atom at exp(mu). 

208 

209 Parameters 

210 ---------- 

211 mu : Union[float, np.ndarray], optional 

212 Mean of underlying normal distribution, by default 0.0 

213 sigma : Union[float, np.ndarray], optional 

214 Standard deviation of underlying normal distribution, by default 1.0 

215 seed : Optional[int], optional 

216 Seed for random number generator, by default None 

217 mean: optional 

218 For alternative mean/std parameterization, the mean of the lognormal distribution 

219 std: optional 

220 For alternative mean/std parameterization, the standard deviation of the lognormal distribution 

221 

222 Returns 

223 ------- 

224 Lognormal or DiscreteDistribution 

225 Lognormal distribution or DiscreteDistribution with a single atom. 

226 """ 

227 

228 if sigma == 0: 

229 # If sigma is zero, return a DiscreteDistribution with a single atom 

230 if mean is not None: 

231 return DiscreteDistribution([1.0], mean, seed=seed) 

232 return DiscreteDistribution([1.0], [np.exp(mu)], seed=seed) 

233 

234 return super().__new__(cls) 

235 

236 def __init__( 

237 self, 

238 mu: Union[float, np.ndarray] = 0.0, 

239 sigma: Union[float, np.ndarray] = 1.0, 

240 seed: Optional[int] = None, 

241 mean=None, 

242 std=None, 

243 ): 

244 if mean is not None and std is not None: 

245 mean_squared = mean**2 

246 variance = std**2 

247 mu = np.log(mean / (np.sqrt(1.0 + variance / mean_squared))) 

248 sigma = np.sqrt(np.log(1.0 + variance / mean_squared)) 

249 

250 if mean is not None and std is None: 

251 mu = np.log(mean) - sigma**2 / 2 

252 

253 self.mu = np.asarray(mu) 

254 self.sigma = np.asarray(sigma) 

255 

256 if self.mu.size != self.sigma.size: 

257 raise AttributeError( 

258 "mu and sigma must be of same size, are %s, %s" 

259 % ((self.mu.size), (self.sigma.size)) 

260 ) 

261 

262 # Set up the RNG 

263 super().__init__(lognorm, s=self.sigma, scale=np.exp(self.mu), loc=0, seed=seed) 

264 self.infimum = np.array([0.0]) 

265 self.supremum = np.array([np.inf]) 

266 

267 def _approx_equiprobable( 

268 self, N, endpoints=False, tail_N=0, tail_bound=None, tail_order=np.e 

269 ): 

270 """ 

271 Construct a discrete approximation to a lognormal distribution with underlying 

272 normal distribution N(mu,sigma). Makes an equiprobable distribution by 

273 default, but user can optionally request augmented tails with exponentially 

274 sized point masses. This can improve solution accuracy in some models. 

275 

276 Parameters 

277 ---------- 

278 N: int 

279 Number of discrete points in the "main part" of the approximation. 

280 tail_N: int 

281 Number of points in each "tail part" of the approximation; 0 = no tail. 

282 tail_bound: [float] 

283 CDF boundaries of the tails vs main portion; tail_bound[0] is the lower 

284 tail bound, tail_bound[1] is the upper tail bound. Inoperative when 

285 tail_N = 0. Can make "one tailed" approximations with 0.0 or 1.0. 

286 tail_order: float 

287 Factor by which consecutive point masses in a "tail part" differ in 

288 probability. Should be >= 1 for sensible spacing. 

289 

290 Returns 

291 ------- 

292 d : DiscreteDistribution 

293 Probability associated with each point in array of discrete 

294 points for discrete probability mass function. 

295 """ 

296 tail_bound = tail_bound or [0.02, 0.98] 

297 

298 # Handle the trivial case first 

299 if self.sigma == 0.0: 

300 pmv = np.ones(N) / N 

301 atoms = np.exp(self.mu) * np.ones(N) 

302 

303 else: 

304 # Find the CDF boundaries of each segment 

305 if tail_N > 0: 

306 lo_cut = tail_bound[0] 

307 hi_cut = tail_bound[1] 

308 else: 

309 lo_cut = 0.0 

310 hi_cut = 1.0 

311 inner_size = hi_cut - lo_cut 

312 inner_CDF_vals = [ 

313 lo_cut + x * N ** (-1.0) * inner_size for x in range(1, N) 

314 ] 

315 if inner_size < 1.0: 

316 scale = 1.0 / tail_order 

317 mag = (1.0 - scale**tail_N) / (1.0 - scale) 

318 lower_CDF_vals = [0.0] 

319 if lo_cut > 0.0: 

320 for x in range(tail_N - 1, -1, -1): 

321 lower_CDF_vals.append(lower_CDF_vals[-1] + lo_cut * scale**x / mag) 

322 upper_CDF_vals = [hi_cut] 

323 if hi_cut < 1.0: 

324 for x in range(tail_N): 

325 upper_CDF_vals.append( 

326 upper_CDF_vals[-1] + (1.0 - hi_cut) * scale**x / mag 

327 ) 

328 CDF_vals = np.array(lower_CDF_vals + inner_CDF_vals + upper_CDF_vals) 

329 CDF_vals[-1] = 1.0 

330 CDF_vals[0] = 0.0 # somehow these need fixing sometimes 

331 

332 # Calculate probability masses for each node 

333 pmv = CDF_vals[1:] - CDF_vals[:-1] 

334 pmv /= np.sum(pmv) 

335 

336 # Translate the CDF values to z-scores (stdevs from mean), then get q-scores 

337 z_cuts = norm.ppf(CDF_vals) 

338 q_cuts = (z_cuts - self.sigma) / np.sqrt(2) 

339 

340 # Evaluate the (complementary) error function at the q values 

341 erf_q = erfc(q_cuts) 

342 erf_q_neg = erfc(-q_cuts) 

343 

344 # Evaluate the base for the conditional expectations 

345 vals_base = erf_q[:-1] - erf_q[1:] 

346 these = q_cuts[:-1] < -2.0 # flag low q values and use the *other* version 

347 vals_base[these] = erf_q_neg[1:][these] - erf_q_neg[:-1][these] 

348 

349 # Make and apply the normalization factor and probability weights 

350 norm_fac = 0.5 * np.exp(self.mu + 0.5 * self.sigma**2) / pmv 

351 atoms = vals_base * norm_fac 

352 

353 if endpoints: 

354 atoms = np.r_[0.0, atoms, np.inf] 

355 pmv = np.r_[0.0, pmv, 0.0] 

356 

357 limit = { 

358 "dist": self, 

359 "method": "equiprobable", 

360 "N": N, 

361 "endpoints": endpoints, 

362 "tail_N": tail_N, 

363 "tail_bound": tail_bound, 

364 "tail_order": tail_order, 

365 } 

366 

367 return DiscreteDistribution( 

368 pmv, 

369 atoms, 

370 seed=self.random_seed(), 

371 limit=limit, 

372 ) 

373 

374 def _approx_hermite(self, N, endpoints=False): 

375 """ 

376 Returns a discrete approximation of this distribution 

377 using the Gauss-Hermite quadrature rule. 

378 

379 Parameters 

380 ---------- 

381 N : int 

382 Number of discrete points to approximate the distribution. 

383 

384 Returns 

385 ------- 

386 DiscreteDistribution 

387 Discrete approximation of this distribution. 

388 """ 

389 

390 x, w = np.polynomial.hermite.hermgauss(N) 

391 # normalize w 

392 pmv = w * np.pi**-0.5 

393 # correct x 

394 atoms = np.exp(np.sqrt(2.0) * self.sigma * x + self.mu) 

395 

396 if endpoints: 

397 atoms = np.r_[0.0, atoms, np.inf] 

398 pmv = np.r_[0.0, pmv, 0.0] 

399 

400 limit = {"dist": self, "method": "hermite", "N": N, "endpoints": endpoints} 

401 

402 return DiscreteDistribution( 

403 pmv, 

404 atoms, 

405 seed=self.random_seed(), 

406 limit=limit, 

407 ) 

408 

409 @classmethod 

410 def from_mean_std(cls, mean, std, seed=None): 

411 """ 

412 Construct a LogNormal distribution from its 

413 mean and standard deviation. 

414 

415 This is unlike the normal constructor for the distribution, 

416 which takes the mu and sigma for the normal distribution 

417 that is the logarithm of the Log Normal distribution. 

418 

419 Parameters 

420 ---------- 

421 mean : float or [float] 

422 One or more means. Number of elements T in mu determines number 

423 of rows of output. 

424 std : float or [float] 

425 One or more standard deviations. Number of elements T in sigma 

426 determines number of rows of output. 

427 seed : int 

428 Seed for random number generator. 

429 

430 Returns 

431 --------- 

432 LogNormal 

433 

434 """ 

435 mean_squared = mean**2 

436 variance = std**2 

437 mu = np.log(mean / (np.sqrt(1.0 + variance / mean_squared))) 

438 sigma = np.sqrt(np.log(1.0 + variance / mean_squared)) 

439 

440 return cls(mu=mu, sigma=sigma, seed=seed) 

441 

442 

443LogNormal = Lognormal 

444 

445 

446class MeanOneLogNormal(Lognormal): 

447 """ 

448 A Lognormal distribution with mean 1. 

449 """ 

450 

451 def __init__(self, sigma=1.0, seed=0): 

452 mu = -0.5 * sigma**2 

453 super().__init__(mu=mu, sigma=sigma, seed=seed) 

454 

455 

456class Uniform(ContinuousFrozenDistribution): 

457 """ 

458 A Uniform distribution. 

459 

460 Parameters 

461 ---------- 

462 bot : float or [float] 

463 One or more bottom values. 

464 Number of elements T in mu determines number 

465 of rows of output. 

466 top : float or [float] 

467 One or more top values. 

468 Number of elements T in top determines number of 

469 rows of output. 

470 seed : int 

471 Seed for random number generator. 

472 """ 

473 

474 def __init__(self, bot=0.0, top=1.0, seed=None): 

475 self.bot = np.asarray(bot) 

476 self.top = np.asarray(top) 

477 

478 super().__init__(uniform, loc=self.bot, scale=self.top - self.bot, seed=seed) 

479 self.infimum = np.array([0.0]) 

480 self.supremum = np.array([np.inf]) 

481 

482 def _approx_equiprobable(self, N, endpoints=False): 

483 """ 

484 Makes a discrete approximation to this uniform distribution. 

485 

486 Parameters 

487 ---------- 

488 N : int 

489 The number of points in the discrete approximation. 

490 endpoints : bool 

491 Whether to include the endpoints in the approximation. 

492 

493 Returns 

494 ------- 

495 d : DiscreteDistribution 

496 Probability associated with each point in array of discrete 

497 points for discrete probability mass function. 

498 """ 

499 pmv = np.ones(N) / float(N) 

500 

501 center = (self.top + self.bot) / 2.0 

502 width = (self.top - self.bot) / 2.0 

503 atoms = center + width * np.linspace(-(N - 1.0) / 2.0, (N - 1.0) / 2.0, N) / ( 

504 N / 2.0 

505 ) 

506 

507 if endpoints: # insert endpoints with infinitesimally small mass 

508 atoms = np.concatenate(([self.bot], atoms, [self.top])) 

509 pmv = np.concatenate(([0.0], pmv, [0.0])) 

510 

511 limit = { 

512 "dist": self, 

513 "method": "equiprobable", 

514 "N": N, 

515 "endpoints": endpoints, 

516 } 

517 

518 return DiscreteDistribution( 

519 pmv, 

520 atoms, 

521 seed=self.random_seed(), 

522 limit=limit, 

523 ) 

524 

525 

526class Weibull(ContinuousFrozenDistribution): 

527 """ 

528 A Weibull distribution. 

529 

530 Parameters 

531 ---------- 

532 scale : float or [float] 

533 One or more scales. Number of elements T in scale 

534 determines number of 

535 rows of output. 

536 shape : float or [float] 

537 One or more shape parameters. Number of elements T in scale 

538 determines number of rows of output. 

539 seed : int 

540 Seed for random number generator. 

541 """ 

542 

543 def __init__(self, scale=1.0, shape=1.0, seed=None): 

544 self.scale = np.asarray(scale) 

545 self.shape = np.asarray(shape) 

546 

547 # Set up the RNG 

548 super().__init__(weibull_min, c=shape, scale=scale, seed=seed) 

549 self.infimum = np.array([0.0]) 

550 self.supremum = np.array([np.inf])