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

139 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-07 05:16 +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 __init__(self, mu=0.0, sigma=1.0, seed=0): 

59 self.mu = np.asarray(mu) 

60 self.sigma = np.asarray(sigma) 

61 

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

63 raise AttributeError( 

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

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

66 ) 

67 

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

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

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

71 

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

73 """ 

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

75 used as the default method for discretization. 

76 """ 

77 

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

79 

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

81 """ 

82 Returns a discrete approximation of this distribution 

83 using the Gauss-Hermite quadrature rule. 

84 

85 Parameters 

86 ---------- 

87 N : int 

88 Number of discrete points to approximate the distribution. 

89 

90 Returns 

91 ------- 

92 DiscreteDistribution 

93 Discrete approximation of this distribution. 

94 """ 

95 

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

97 # normalize w 

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

99 # correct x 

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

101 

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

103 

104 return DiscreteDistribution( 

105 pmv, 

106 atoms, 

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

108 limit=limit, 

109 ) 

110 

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

112 """ 

113 Returns a discrete equiprobable approximation of this distribution. 

114 

115 Parameters 

116 ---------- 

117 N : int 

118 Number of discrete points to approximate the distribution. 

119 

120 Returns 

121 ------- 

122 DiscreteDistribution 

123 Discrete approximation of this distribution. 

124 """ 

125 

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

127 lims = norm.ppf(CDF) 

128 pdf = norm.pdf(lims) 

129 

130 # Find conditional means using Mills's ratio 

131 pmv = np.diff(CDF) 

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

133 

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

135 

136 return DiscreteDistribution( 

137 pmv, 

138 atoms, 

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

140 limit=limit, 

141 ) 

142 

143 

144class Lognormal(ContinuousFrozenDistribution): 

145 """ 

146 A Lognormal distribution 

147 

148 Parameters 

149 ---------- 

150 mu : float or [float] 

151 One or more means of underlying normal distribution. 

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

153 sigma : float or [float] 

154 One or more standard deviations of underlying normal distribution. 

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

156 seed : int 

157 Seed for random number generator. 

158 """ 

159 

160 def __new__( 

161 cls, 

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

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

164 seed: Optional[int] = 0, 

165 mean=None, 

166 std=None, 

167 ): 

168 """ 

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

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

171 

172 Parameters 

173 ---------- 

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

175 Mean of underlying normal distribution, by default 0.0 

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

177 Standard deviation of underlying normal distribution, by default 1.0 

178 seed : Optional[int], optional 

179 Seed for random number generator, by default None 

180 mean: optional 

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

182 std: optional 

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

184 

185 Returns 

186 ------- 

187 Lognormal or DiscreteDistribution 

188 Lognormal distribution or DiscreteDistribution with a single atom. 

189 """ 

190 

191 if sigma == 0: 

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

193 if mean is not None: 

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

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

196 

197 return super().__new__(cls) 

198 

199 def __init__( 

200 self, 

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

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

203 seed: Optional[int] = 0, 

204 mean=None, 

205 std=None, 

206 ): 

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

208 mean_squared = mean**2 

209 variance = std**2 

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

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

212 

213 if mean is not None and std is None: 

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

215 

216 self.mu = np.asarray(mu) 

217 self.sigma = np.asarray(sigma) 

218 

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

220 raise AttributeError( 

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

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

223 ) 

224 

225 # Set up the RNG 

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

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

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

229 

230 def _approx_equiprobable( 

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

232 ): 

233 """ 

234 Construct a discrete approximation to a lognormal distribution with underlying 

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

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

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

238 

239 TODO: add endpoints option 

240 

241 Parameters 

242 ---------- 

243 N: int 

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

245 tail_N: int 

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

247 tail_bound: [float] 

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

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

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

251 tail_order: float 

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

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

254 

255 Returns 

256 ------- 

257 d : DiscreteDistribution 

258 Probability associated with each point in array of discrete 

259 points for discrete probability mass function. 

260 """ 

261 tail_bound = tail_bound if tail_bound is not None else [0.02, 0.98] 

262 

263 # Handle the trivial case first 

264 if self.sigma == 0.0: 

265 pmv = np.ones(N) / N 

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

267 

268 else: 

269 # Find the CDF boundaries of each segment 

270 if tail_N > 0: 

271 lo_cut = tail_bound[0] 

272 hi_cut = tail_bound[1] 

273 else: 

274 lo_cut = 0.0 

275 hi_cut = 1.0 

276 inner_size = hi_cut - lo_cut 

277 inner_CDF_vals = [ 

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

279 ] 

280 if inner_size < 1.0: 

281 scale = 1.0 / tail_order 

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

283 lower_CDF_vals = [0.0] 

284 if lo_cut > 0.0: 

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

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

287 upper_CDF_vals = [hi_cut] 

288 if hi_cut < 1.0: 

289 for x in range(tail_N): 

290 upper_CDF_vals.append( 

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

292 ) 

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

294 CDF_vals[-1] = 1.0 

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

296 

297 # Calculate probability masses for each node 

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

299 pmv /= np.sum(pmv) 

300 

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

302 z_cuts = norm.ppf(CDF_vals) 

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

304 

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

306 erf_q = erfc(q_cuts) 

307 erf_q_neg = erfc(-q_cuts) 

308 

309 # Evaluate the base for the conditional expectations 

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

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

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

313 

314 # Make and apply the normalization factor and probability weights 

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

316 atoms = vals_base * norm_fac 

317 

318 limit = { 

319 "dist": self, 

320 "method": "equiprobable", 

321 "N": N, 

322 "endpoints": endpoints, 

323 "tail_N": tail_N, 

324 "tail_bound": tail_bound, 

325 "tail_order": tail_order, 

326 } 

327 

328 return DiscreteDistribution( 

329 pmv, 

330 atoms, 

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

332 limit=limit, 

333 ) 

334 

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

336 """ 

337 Returns a discrete approximation of this distribution 

338 using the Gauss-Hermite quadrature rule. 

339 

340 Parameters 

341 ---------- 

342 N : int 

343 Number of discrete points to approximate the distribution. 

344 

345 Returns 

346 ------- 

347 DiscreteDistribution 

348 Discrete approximation of this distribution. 

349 """ 

350 

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

352 # normalize w 

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

354 # correct x 

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

356 

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

358 

359 return DiscreteDistribution( 

360 pmv, 

361 atoms, 

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

363 limit=limit, 

364 ) 

365 

366 @classmethod 

367 def from_mean_std(cls, mean, std, seed=0): 

368 """ 

369 Construct a LogNormal distribution from its 

370 mean and standard deviation. 

371 

372 This is unlike the normal constructor for the distribution, 

373 which takes the mu and sigma for the normal distribution 

374 that is the logarithm of the Log Normal distribution. 

375 

376 Parameters 

377 ---------- 

378 mean : float or [float] 

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

380 of rows of output. 

381 std : float or [float] 

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

383 determines number of rows of output. 

384 seed : int 

385 Seed for random number generator. 

386 

387 Returns 

388 --------- 

389 LogNormal 

390 

391 """ 

392 mean_squared = mean**2 

393 variance = std**2 

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

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

396 

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

398 

399 

400LogNormal = Lognormal 

401 

402 

403class MeanOneLogNormal(Lognormal): 

404 """ 

405 A Lognormal distribution with mean 1. 

406 """ 

407 

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

409 mu = -0.5 * sigma**2 

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

411 

412 

413class Uniform(ContinuousFrozenDistribution): 

414 """ 

415 A Uniform distribution. 

416 

417 Parameters 

418 ---------- 

419 bot : float or [float] 

420 One or more bottom values. 

421 Number of elements T in mu determines number 

422 of rows of output. 

423 top : float or [float] 

424 One or more top values. 

425 Number of elements T in top determines number of 

426 rows of output. 

427 seed : int 

428 Seed for random number generator. 

429 """ 

430 

431 def __init__(self, bot=0.0, top=1.0, seed=0): 

432 self.bot = np.asarray(bot) 

433 self.top = np.asarray(top) 

434 

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

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

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

438 

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

440 """ 

441 Makes a discrete approximation to this uniform distribution. 

442 

443 Parameters 

444 ---------- 

445 N : int 

446 The number of points in the discrete approximation. 

447 endpoints : bool 

448 Whether to include the endpoints in the approximation. 

449 

450 Returns 

451 ------- 

452 d : DiscreteDistribution 

453 Probability associated with each point in array of discrete 

454 points for discrete probability mass function. 

455 """ 

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

457 

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

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

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

461 N / 2.0 

462 ) 

463 

464 if endpoints: # insert endpoints with infinitesimally small mass 

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

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

467 

468 limit = { 

469 "dist": self, 

470 "method": "equiprobable", 

471 "N": N, 

472 "endpoints": endpoints, 

473 } 

474 

475 return DiscreteDistribution( 

476 pmv, 

477 atoms, 

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

479 limit=limit, 

480 ) 

481 

482 

483class Weibull(ContinuousFrozenDistribution): 

484 """ 

485 A Weibull distribution. 

486 

487 Parameters 

488 ---------- 

489 scale : float or [float] 

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

491 determines number of 

492 rows of output. 

493 shape : float or [float] 

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

495 determines number of rows of output. 

496 seed : int 

497 Seed for random number generator. 

498 """ 

499 

500 def __init__(self, scale=1.0, shape=1.0, seed=0): 

501 self.scale = np.asarray(scale) 

502 self.shape = np.asarray(shape) 

503 

504 # Set up the RNG 

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

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

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