Coverage for HARK / utilities.py: 93%

267 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-08 05:31 +0000

1""" 

2General purpose / miscellaneous functions. Includes functions to approximate 

3continuous distributions with discrete ones, utility functions (and their 

4derivatives), manipulation of discrete distributions, and basic plotting tools. 

5""" 

6 

7import cProfile 

8import os 

9import pstats 

10import re 

11 

12import numba 

13import numpy as np # Python's numeric library, abbreviated "np" 

14from scipy.interpolate import interp1d 

15 

16from inspect import signature 

17 

18 

19class get_it_from: 

20 """ 

21 Class whose instances act as a special case trivial constructor that merely 

22 grabs an attribute or entry from the named attribute. This is useful when 

23 there are constructed model inputs that are "built together". Simply have a 

24 constructor that makes a dictionary (or object) containing the several inputs, 

25 then use get_it_from(that_dict_name) as the constructor for each of them. 

26 

27 Parameters 

28 ---------- 

29 name : str 

30 Name of the parent dictionary or object from which to take the object. 

31 """ 

32 

33 def __init__(self, name): 

34 self.name = name 

35 

36 def __call__(self, parent, query): 

37 if isinstance(parent, (int, float, bool, complex, str)): 

38 return parent # the desired result is the thing itself 

39 elif isinstance(parent, dict): 

40 return parent[query] 

41 else: 

42 return getattr(parent, query) 

43 

44 

45# ============================================================================== 

46# ============== Some basic function tools ==================================== 

47# ============================================================================== 

48 

49 

50def get_arg_names(function): 

51 """ 

52 Returns a list of strings naming all of the arguments for the passed function. 

53 

54 Parameters 

55 ---------- 

56 function : function 

57 A function whose argument names are wanted. 

58 

59 Returns 

60 ------- 

61 argNames : [string] 

62 The names of the arguments of function. 

63 """ 

64 argCount = function.__code__.co_argcount 

65 argNames = function.__code__.co_varnames[:argCount] 

66 return argNames 

67 

68 

69class NullFunc: 

70 """ 

71 A trivial class that acts as a placeholder "do nothing" function. 

72 """ 

73 

74 def __call__(self, *args): 

75 """ 

76 Returns meaningless output no matter what the input(s) is. If no input, 

77 returns None. Otherwise, returns an array of NaNs (or a single NaN) of 

78 the same size as the first input. 

79 """ 

80 if len(args) == 0: 

81 return None 

82 else: 

83 arg = args[0] 

84 if hasattr(arg, "shape"): 

85 return np.zeros_like(arg) + np.nan 

86 else: 

87 return np.nan 

88 

89 def distance(self, other): 

90 """ 

91 Trivial distance metric that only cares whether the other object is also 

92 an instance of NullFunc. Intentionally does not inherit from HARKobject 

93 as this might create dependency problems. 

94 

95 Parameters 

96 ---------- 

97 other : any 

98 Any object for comparison to this instance of NullFunc. 

99 

100 Returns 

101 ------- 

102 (unnamed) : float 

103 The distance between self and other. Returns 0 if other is also a 

104 NullFunc; otherwise returns an arbitrary high number. 

105 """ 

106 if other.__class__ is self.__class__: 

107 return 0.0 

108 else: 

109 return 1000.0 

110 

111 

112def apply_fun_to_vals(fun, vals): 

113 """ 

114 Applies a function to the arguments defined in `vals`. 

115 This is equivalent to `fun(**vals)`, except 

116 that `vals` may contain keys that are not named arguments 

117 of `fun`. 

118 

119 Parameters 

120 ---------- 

121 fun: callable 

122 

123 vals: dict 

124 """ 

125 return fun(*[vals[var] for var in signature(fun).parameters]) 

126 

127 

128# ======================================================= 

129# ================ Other useful functions =============== 

130# ======================================================= 

131 

132 

133def make_assets_grid(aXtraMin, aXtraMax, aXtraCount, aXtraExtra, aXtraNestFac): 

134 """ 

135 Constructs the base grid of post-decision states, representing end-of-period 

136 assets above the absolute minimum. Has three modes, depending on aXtraNestFac: 

137 

138 aXtraNestFac = -1 : Uniformly spaced grid. 

139 aXtraNestFac = 0 : Ordinary exponentially spaced grid. 

140 aXtraNestFac >= 1 : Multi-exponentially nested grid. 

141 

142 See :func:`HARK.utilities.make_grid_exp_mult` for more info 

143 

144 Parameters 

145 ---------- 

146 aXtraMin: float 

147 Minimum value for the assets-above-minimum grid. 

148 aXtraMax: float 

149 Maximum value for the assets-above-minimum grid. 

150 aXtraCount: int 

151 Number of nodes in the assets-above-minimum grid, not counting extra values. 

152 aXtraExtra: [float] 

153 Additional values to insert in the assets-above-minimum grid. 

154 aXtraNestFac: int 

155 Level of exponential nesting for grid. If -1, the grid is linearly spaced. 

156 

157 Returns 

158 ------- 

159 aXtraGrid: np.ndarray 

160 Base array of values for the post-decision-state grid. 

161 """ 

162 # Set up post decision state grid: 

163 if aXtraNestFac == -1: 

164 aXtraGrid = np.linspace(aXtraMin, aXtraMax, aXtraCount) 

165 elif (aXtraNestFac >= 0) and type(aXtraNestFac) is int: 

166 aXtraGrid = make_grid_exp_mult( 

167 ming=aXtraMin, maxg=aXtraMax, ng=aXtraCount, timestonest=aXtraNestFac 

168 ) 

169 else: 

170 raise ValueError( 

171 "Please ensure aXtraNestFac is either -1 or a positive integer." 

172 ) 

173 

174 # Add in additional points for the grid: 

175 if aXtraExtra is not None: 

176 temp_list = [] 

177 for i in aXtraExtra: 

178 if i is not None: 

179 temp_list.append(i) 

180 aXtraGrid = np.sort(np.unique(np.concatenate((aXtraGrid, temp_list)))) 

181 

182 return aXtraGrid 

183 

184 

185# ============================================================================== 

186# ============== Functions for generating state space grids =================== 

187# ============================================================================== 

188 

189 

190def make_grid_exp_mult(ming, maxg, ng, timestonest=20): 

191 r""" 

192 Makes a multi-exponentially spaced grid. 

193 If the function :math:`\ln(1+x)` were applied timestonest times, the grid would 

194 become linearly spaced. If timestonest is 0, the grid is exponentially spaced. 

195 If timestonest is -1, the grid is linearly spaced. 

196 

197 NOTE: The bounds of the grid must be non-negative, else this function will 

198 return an invalid grid with NaNs in it. If you want a non-linearly spaced 

199 grid that spans negative numbers, use make_exponential_grid; see below. 

200 

201 Parameters 

202 ---------- 

203 ming : float 

204 Minimum value of the grid, which must be non-negative. 

205 maxg : float 

206 Maximum value of the grid, which must be greater than ming. 

207 ng : int 

208 The number of grid points 

209 timestonest : int 

210 the number of times to nest the exponentiation 

211 

212 Returns 

213 ------- 

214 points : np.array 

215 A multi-exponentially spaced grid 

216 

217 Notes 

218 ----- 

219 Original Matab code can be found in Chris Carroll's 

220 [Solution Methods for Microeconomic Dynamic Optimization Problems] 

221 (https://www.econ2.jhu.edu/people/ccarroll/solvingmicrodsops/) toolkit. 

222 Latest update: 01 May 2015 

223 """ 

224 if timestonest == -1: 

225 grid = np.linspace(ming, maxg, ng) 

226 return grid 

227 if timestonest > 0: 

228 Lming = ming 

229 Lmaxg = maxg 

230 for j in range(timestonest): 

231 Lming = np.log(Lming + 1) 

232 Lmaxg = np.log(Lmaxg + 1) 

233 Lgrid = np.linspace(Lming, Lmaxg, ng) 

234 grid = Lgrid 

235 for j in range(timestonest): 

236 grid = np.exp(grid) - 1 

237 else: 

238 Lming = np.log(ming) 

239 Lmaxg = np.log(maxg) 

240 Lgrid = np.linspace(Lming, Lmaxg, ng) 

241 grid = np.exp(Lgrid) 

242 return grid 

243 

244 

245def make_exponential_grid(ming, maxg, ng, order=1.0): 

246 """ 

247 Construct an exponentially spaced grid with chosen exponential order. 

248 A uniformly spaced grid on [0,1] is raised to the chosen order, then linearly 

249 remapped to the specified interval. Supports any real valued grid bounds. 

250 

251 Parameters 

252 ---------- 

253 ming : float 

254 Lower bound of grid. 

255 maxg : float 

256 Upper bound of grid. 

257 ng : int 

258 Number of points in the grid. 

259 order : float, optional 

260 Exponential spacing order for the grid. The default is 1.0, or linear. 

261 

262 Returns 

263 ------- 

264 grid : np.array 

265 Exponentially spaced grid on [ming, maxg] with ng points. 

266 """ 

267 grid = np.linspace(0.0, 1.0, ng) ** order * (maxg - ming) + ming 

268 return grid 

269 

270 

271# ============================================================================== 

272# ============== Uncategorized general functions =================== 

273# ============================================================================== 

274 

275 

276def get_percentiles(data, weights=None, percentiles=None, presorted=False): 

277 """ 

278 Calculates the requested percentiles of (weighted) data. Median by default. 

279 

280 Parameters 

281 ---------- 

282 data : numpy.array 

283 A 1D array of float data. 

284 weights : np.array 

285 A weighting vector for the data. 

286 percentiles : [float] 

287 A list or numpy.array of percentiles to calculate for the data. Each element should 

288 be in (0,1). 

289 presorted : boolean 

290 Indicator for whether data has already been sorted. 

291 

292 Returns 

293 ------- 

294 pctl_out : numpy.array 

295 The requested percentiles of the data. 

296 """ 

297 if percentiles is None: 

298 percentiles = [0.5] 

299 else: 

300 if ( 

301 not isinstance(percentiles, (list, np.ndarray)) 

302 or min(percentiles) <= 0 

303 or max(percentiles) >= 1 

304 ): 

305 raise ValueError( 

306 "Percentiles should be a list or numpy array of floats between 0 and 1" 

307 ) 

308 

309 if data.size < 2: 

310 return np.zeros(np.array(percentiles).shape) + np.nan 

311 

312 if weights is None: # Set equiprobable weights if none were passed 

313 weights = np.ones(data.size) / float(data.size) 

314 

315 if presorted: # Sort the data if it is not already 

316 data_sorted = data 

317 weights_sorted = weights 

318 else: 

319 order = np.argsort(data) 

320 data_sorted = data[order] 

321 weights_sorted = weights[order] 

322 

323 cum_dist = np.cumsum(weights_sorted) / np.sum( 

324 weights_sorted 

325 ) # cumulative probability distribution 

326 

327 # Calculate the requested percentiles by interpolating the data over the 

328 # cumulative distribution, then evaluating at the percentile values 

329 inv_CDF = interp1d(cum_dist, data_sorted, bounds_error=False, assume_sorted=True) 

330 pctl_out = inv_CDF(percentiles) 

331 return pctl_out 

332 

333 

334def get_lorenz_shares(data, weights=None, percentiles=None, presorted=False): 

335 """ 

336 Calculates the Lorenz curve at the requested percentiles of (weighted) data. 

337 Median by default. 

338 

339 Parameters 

340 ---------- 

341 data : numpy.array 

342 A 1D array of float data. 

343 weights : numpy.array 

344 A weighting vector for the data. 

345 percentiles : [float] 

346 A list or numpy.array of percentiles to calculate for the data. Each element should 

347 be in (0,1). 

348 presorted : boolean 

349 Indicator for whether data has already been sorted. 

350 

351 Returns 

352 ------- 

353 lorenz_out : numpy.array 

354 The requested Lorenz curve points of the data. 

355 """ 

356 if percentiles is None: 

357 percentiles = [0.5] 

358 else: 

359 if ( 

360 not isinstance(percentiles, (list, np.ndarray)) 

361 or min(percentiles) <= 0 

362 or max(percentiles) >= 1 

363 ): 

364 raise ValueError( 

365 "Percentiles should be a list or numpy array of floats between 0 and 1" 

366 ) 

367 if weights is None: # Set equiprobable weights if none were given 

368 weights = np.ones(data.size) 

369 

370 if presorted: # Sort the data if it is not already 

371 data_sorted = data 

372 weights_sorted = weights 

373 else: 

374 order = np.argsort(data) 

375 data_sorted = data[order] 

376 weights_sorted = weights[order] 

377 

378 cum_dist = np.cumsum(weights_sorted) / np.sum( 

379 weights_sorted 

380 ) # cumulative probability distribution 

381 temp = data_sorted * weights_sorted 

382 cum_data = np.cumsum(temp) / sum(temp) # cumulative ownership shares 

383 

384 # Calculate the requested Lorenz shares by interpolating the cumulative ownership 

385 # shares over the cumulative distribution, then evaluating at requested points 

386 lorenzFunc = interp1d(cum_dist, cum_data, bounds_error=False, assume_sorted=True) 

387 lorenz_out = lorenzFunc(percentiles) 

388 return lorenz_out 

389 

390 

391def calc_subpop_avg(data, reference, cutoffs, weights=None): 

392 """ 

393 Calculates the average of (weighted) data between cutoff percentiles of a 

394 reference variable. 

395 

396 Parameters 

397 ---------- 

398 data : numpy.array 

399 A 1D array of float data. 

400 reference : numpy.array 

401 A 1D array of float data of the same length as data. 

402 cutoffs : [(float,float)] 

403 A list of doubles with the lower and upper percentile bounds (should be 

404 in [0,1]). 

405 weights : numpy.array 

406 A weighting vector for the data. 

407 

408 Returns 

409 ------- 

410 slice_avg 

411 The (weighted) average of data that falls within the cutoff percentiles 

412 of reference. 

413 

414 """ 

415 if weights is None: # Set equiprobable weights if none were given 

416 weights = np.ones(data.size) 

417 

418 # Sort the data and generate a cumulative distribution 

419 order = np.argsort(reference) 

420 data_sorted = data[order] 

421 weights_sorted = weights[order] 

422 cum_dist = np.cumsum(weights_sorted) / np.sum(weights_sorted) 

423 

424 # For each set of cutoffs, calculate the average of data that falls within 

425 # the cutoff percentiles of reference 

426 slice_avg = [] 

427 for j in range(len(cutoffs)): 

428 bot = np.searchsorted(cum_dist, cutoffs[j][0]) 

429 top = np.searchsorted(cum_dist, cutoffs[j][1]) 

430 slice_avg.append( 

431 np.sum(data_sorted[bot:top] * weights_sorted[bot:top]) 

432 / np.sum(weights_sorted[bot:top]) 

433 ) 

434 return slice_avg 

435 

436 

437def epanechnikov_kernel(x, ref_x, h=1.0): 

438 """ 

439 The Epanechnikov kernel, which has been shown to be the most efficient kernel 

440 for non-parametric regression. 

441 

442 Parameters 

443 ---------- 

444 x : np.array 

445 Values at which to evaluate the kernel 

446 x_ref : float 

447 The reference point 

448 h : float 

449 Kernel bandwidth 

450 

451 Returns 

452 ------- 

453 out : np.array 

454 Kernel values at each value of x 

455 """ 

456 u = (x - ref_x) / h # Normalize distance by bandwidth 

457 out = 0.75 * (1.0 - u**2) 

458 out[np.abs(u) > 1.0] = 0.0 # Kernel = 0 outside [-1,1] 

459 return out 

460 

461 

462def triangle_kernel(x, ref_x, h=1.0): 

463 """ 

464 The triangle or "hat" kernel. 

465 

466 Parameters 

467 ---------- 

468 x : np.array 

469 Values at which to evaluate the kernel 

470 x_ref : float 

471 The reference point 

472 h : float 

473 Kernel bandwidth 

474 

475 Returns 

476 ------- 

477 out : np.array 

478 Kernel values at each value of x 

479 """ 

480 u = (x - ref_x) / h # Normalize distance by bandwidth 

481 these = np.abs(u) <= 1.0 # Kernel = 0 outside [-1,1] 

482 out = np.zeros_like(u) # Initialize kernel output 

483 out[these] = 1.0 - np.abs(u[these]) # Evaluate kernel 

484 return out 

485 

486 

487kernel_dict = { 

488 "epanechnikov": epanechnikov_kernel, 

489 "triangle": triangle_kernel, 

490 "hat": triangle_kernel, 

491} 

492 

493 

494def kernel_regression(x, y, bot=None, top=None, N=500, h=None, kernel="epanechnikov"): 

495 """ 

496 Performs a non-parametric Nadaraya-Watson 1D kernel regression on given data 

497 with optionally specified range, number of points, and kernel bandwidth. 

498 

499 Parameters 

500 ---------- 

501 x : np.array 

502 The independent variable in the kernel regression. 

503 y : np.array 

504 The dependent variable in the kernel regression. 

505 bot : float 

506 Minimum value of interest in the regression; defaults to min(x). 

507 top : float 

508 Maximum value of interest in the regression; defaults to max(y). 

509 N : int 

510 Number of points to compute. 

511 h : float 

512 The bandwidth of the (Epanechnikov) kernel. To-do: GENERALIZE. 

513 

514 Returns 

515 ------- 

516 regression : LinearInterp 

517 A piecewise locally linear kernel regression: y = f(x). 

518 """ 

519 # Fix omitted inputs 

520 if bot is None: 

521 bot = np.min(x) 

522 if top is None: 

523 top = np.max(x) 

524 if h is None: 

525 h = 2.0 * (top - bot) / float(N) # This is an arbitrary default 

526 

527 # Get kernel if possible 

528 try: 

529 kern = kernel_dict[kernel] 

530 except: 

531 raise ValueError("Can't find a kernel named '" + kernel + "'!") 

532 

533 # Construct a local linear approximation 

534 x_vec = np.linspace(bot, top, num=N) 

535 # Evaluate the kernel for all evaluation points at once 

536 weights = kern(x[:, None], x_vec[None, :], h) 

537 weight_sums = np.sum(weights, axis=0) 

538 # Avoid division by zero when weights are extremely small 

539 weight_sums[weight_sums == 0] = np.nan 

540 y_vec = np.dot(weights.T, y) / weight_sums 

541 regression = interp1d(x_vec, y_vec, bounds_error=False, assume_sorted=True) 

542 return regression 

543 

544 

545def make_polynomial_params(coeffs, T, offset=0.0, step=1.0): 

546 """ 

547 Make a T-length array of parameters using polynomial coefficients. 

548 

549 Parameters 

550 ---------- 

551 coeffs : [float] 

552 Arbitrary length list of polynomial coefficients. 

553 T : int 

554 Number of elements in the output. 

555 offset : float, optional 

556 Value at which the X values should start. The default is 0.0. 

557 step : float, optional 

558 Increment of the X values. The default is 1.0. 

559 

560 Returns 

561 ------- 

562 param_vals : np.array 

563 T-length array of parameter values calculated using the polynomial coefficients. 

564 """ 

565 X = offset + step * np.arange(T) 

566 # np.polyval expects highest power first 

567 return np.polyval(coeffs[::-1], X) 

568 

569 

570@numba.njit 

571def jump_to_grid_1D(m_vals, probs, Dist_mGrid): # pragma: nocover 

572 """ 

573 Distributes values onto a predefined grid, maintaining the means. 

574 

575 

576 Parameters 

577 ---------- 

578 m_vals: np.array 

579 Market resource values 

580 

581 probs: np.array 

582 Shock probabilities associated with combinations of m_vals. 

583 Can be thought of as the probability mass function of (m_vals). 

584 

585 dist_mGrid : np.array 

586 Grid over normalized market resources 

587 

588 Returns 

589 ------- 

590 probGrid.flatten(): np.array 

591 Probabilities of each gridpoint on the combined grid of market resources 

592 

593 """ 

594 

595 probGrid = np.zeros(len(Dist_mGrid)) 

596 mIndex = np.digitize(m_vals, Dist_mGrid) - 1 

597 mIndex[m_vals <= Dist_mGrid[0]] = -1 

598 mIndex[m_vals >= Dist_mGrid[-1]] = len(Dist_mGrid) - 1 

599 

600 for i in range(len(m_vals)): 

601 if mIndex[i] == -1: 

602 mlowerIndex = 0 

603 mupperIndex = 0 

604 mlowerWeight = 1.0 

605 mupperWeight = 0.0 

606 elif mIndex[i] == len(Dist_mGrid) - 1: 

607 mlowerIndex = -1 

608 mupperIndex = -1 

609 mlowerWeight = 1.0 

610 mupperWeight = 0.0 

611 else: 

612 mlowerIndex = mIndex[i] 

613 mupperIndex = mIndex[i] + 1 

614 mlowerWeight = (Dist_mGrid[mupperIndex] - m_vals[i]) / ( 

615 Dist_mGrid[mupperIndex] - Dist_mGrid[mlowerIndex] 

616 ) 

617 mupperWeight = 1.0 - mlowerWeight 

618 

619 probGrid[mlowerIndex] += probs[i] * mlowerWeight 

620 probGrid[mupperIndex] += probs[i] * mupperWeight 

621 

622 return probGrid.flatten() 

623 

624 

625@numba.njit 

626def jump_to_grid_2D( 

627 m_vals, perm_vals, probs, dist_mGrid, dist_pGrid 

628): # pragma: nocover 

629 """ 

630 Distributes values onto a predefined grid, maintaining the means. m_vals and perm_vals are realizations of market resources and permanent income while 

631 dist_mGrid and dist_pGrid are the predefined grids of market resources and permanent income, respectively. That is, m_vals and perm_vals do not necesarily lie on their 

632 respective grids. Returns probabilities of each gridpoint on the combined grid of market resources and permanent income. 

633 

634 

635 Parameters 

636 ---------- 

637 m_vals: np.array 

638 Market resource values 

639 

640 perm_vals: np.array 

641 Permanent income values 

642 

643 probs: np.array 

644 Shock probabilities associated with combinations of m_vals and perm_vals. 

645 Can be thought of as the probability mass function of (m_vals, perm_vals). 

646 

647 dist_mGrid : np.array 

648 Grid over normalized market resources 

649 

650 dist_pGrid : np.array 

651 Grid over permanent income 

652 Returns 

653 ------- 

654 probGrid.flatten(): np.array 

655 Probabilities of each gridpoint on the combined grid of market resources and permanent income 

656 """ 

657 

658 probGrid = np.zeros((len(dist_mGrid), len(dist_pGrid))) 

659 

660 # Maybe use np.searchsorted as opposed to np.digitize 

661 mIndex = ( 

662 np.digitize(m_vals, dist_mGrid) - 1 

663 ) # Array indicating in which bin each values of m_vals lies in relative to dist_mGrid. Bins lie between between point of Dist_mGrid. 

664 # For instance, if mval lies between dist_mGrid[4] and dist_mGrid[5] it is in bin 4 (would be 5 if 1 was not subtracted in the previous line). 

665 mIndex[ 

666 m_vals <= dist_mGrid[0] 

667 ] = -1 # if the value is less than the smallest value on dist_mGrid assign it an index of -1 

668 mIndex[m_vals >= dist_mGrid[-1]] = ( 

669 len(dist_mGrid) - 1 

670 ) # if value if greater than largest value on dist_mGrid assign it an index of the length of the grid minus 1 

671 

672 # the following three lines hold the same intuition as above 

673 pIndex = np.digitize(perm_vals, dist_pGrid) - 1 

674 pIndex[perm_vals <= dist_pGrid[0]] = -1 

675 pIndex[perm_vals >= dist_pGrid[-1]] = len(dist_pGrid) - 1 

676 

677 for i in range(len(m_vals)): 

678 if ( 

679 mIndex[i] == -1 

680 ): # if mval is below smallest gridpoint, then assign it a weight of 1.0 for lower weight. 

681 mlowerIndex = 0 

682 mupperIndex = 0 

683 mlowerWeight = 1.0 

684 mupperWeight = 0.0 

685 elif ( 

686 mIndex[i] == len(dist_mGrid) - 1 

687 ): # if mval is greater than maximum gridpoint, then assign the following weights 

688 mlowerIndex = -1 

689 mupperIndex = -1 

690 mlowerWeight = 1.0 

691 mupperWeight = 0.0 

692 else: # Standard case where mval does not lie past any extremes 

693 # identify which two points on the grid the mval is inbetween 

694 mlowerIndex = mIndex[i] 

695 mupperIndex = mIndex[i] + 1 

696 # Assign weight to the indices that bound the m_vals point. Intuitively, an mval perfectly between two points on the mgrid will assign a weight of .5 to the gridpoint above and below 

697 mlowerWeight = ( 

698 (dist_mGrid[mupperIndex] - m_vals[i]) 

699 / (dist_mGrid[mupperIndex] - dist_mGrid[mlowerIndex]) 

700 ) # Metric to determine weight of gridpoint/index below. Intuitively, mvals that are close to gridpoint/index above are assigned a smaller mlowerweight. 

701 mupperWeight = 1.0 - mlowerWeight # weight of gridpoint/ index above 

702 

703 # Same logic as above except the weights here concern the permanent income grid 

704 if pIndex[i] == -1: 

705 plowerIndex = 0 

706 pupperIndex = 0 

707 plowerWeight = 1.0 

708 pupperWeight = 0.0 

709 elif pIndex[i] == len(dist_pGrid) - 1: 

710 plowerIndex = -1 

711 pupperIndex = -1 

712 plowerWeight = 1.0 

713 pupperWeight = 0.0 

714 else: 

715 plowerIndex = pIndex[i] 

716 pupperIndex = pIndex[i] + 1 

717 plowerWeight = (dist_pGrid[pupperIndex] - perm_vals[i]) / ( 

718 dist_pGrid[pupperIndex] - dist_pGrid[plowerIndex] 

719 ) 

720 pupperWeight = 1.0 - plowerWeight 

721 

722 # Compute probabilities of each gridpoint on the combined market resources and permanent income grid by looping through each point on the combined market resources and permanent income grid, 

723 # assigning probabilities to each gridpoint based off the probabilities of the surrounding mvals and pvals and their respective weights placed on the gridpoint. 

724 # Note* probs[i] is the probability of mval AND pval occurring 

725 

726 probGrid[mlowerIndex][plowerIndex] += ( 

727 probs[i] * mlowerWeight * plowerWeight 

728 ) # probability of gridpoint below mval and pval 

729 probGrid[mlowerIndex][pupperIndex] += ( 

730 probs[i] * mlowerWeight * pupperWeight 

731 ) # probability of gridpoint below mval and above pval 

732 probGrid[mupperIndex][plowerIndex] += ( 

733 probs[i] * mupperWeight * plowerWeight 

734 ) # probability of gridpoint above mval and below pval 

735 probGrid[mupperIndex][pupperIndex] += ( 

736 probs[i] * mupperWeight * pupperWeight 

737 ) # probability of gridpoint above mval and above pval 

738 

739 return probGrid.flatten() 

740 

741 

742@numba.njit(parallel=True) 

743def gen_tran_matrix_1D( 

744 dist_mGrid, bNext, shk_prbs, perm_shks, tran_shks, LivPrb, NewBornDist 

745): # pragma: nocover 

746 """ 

747 Computes Transition Matrix across normalized market resources. 

748 This function is built to non-stochastic simulate the IndShockConsumerType. 

749 This function is used exclusively when Harmenberg Neutral Measure is applied and/or if permanent income is not a state variable 

750 For more information, see https://econ-ark.org/materials/harmenberg-aggregation?launch 

751 

752 Parameters 

753 ---------- 

754 dist_mGrid : np.array 

755 Grid over normalized market resources 

756 

757 bNext : np.array 

758 Grid over bank balances 

759 

760 shk_prbs : np.array 

761 Array of shock probabilities over combinations of permanent and transitory shocks 

762 

763 perm_shks : np.array 

764 Array of shocks to permanent income. Shocks should follow Harmenberg neutral measure 

765 

766 tran_shks : np.array 

767 Array of shocks to transitory 

768 

769 LivPrb : float 

770 Probability of not dying 

771 

772 NewBornDist : np.array 

773 array representing distribution of newborns across grid of normalized market resources and grid of permanent income. 

774 

775 Returns 

776 ------- 

777 TranMatrix : np.array 

778 Transition Matrix over normalized market resources grid. 

779 

780 

781 """ 

782 

783 TranMatrix = np.zeros((len(dist_mGrid), len(dist_mGrid))) 

784 for i in numba.prange(len(dist_mGrid)): 

785 mNext_ij = ( 

786 bNext[i] / perm_shks + tran_shks 

787 ) # Compute next period's market resources given todays bank balances bnext[i] 

788 TranMatrix[:, i] += ( 

789 LivPrb * jump_to_grid_1D(mNext_ij, shk_prbs, dist_mGrid) 

790 + (1.0 - LivPrb) * NewBornDist 

791 ) # this is the transition matrix if given you are unemployed today and unemployed tomorrow so you assume the unemployed consumption policy 

792 return TranMatrix 

793 

794 

795@numba.njit(parallel=True) 

796def gen_tran_matrix_2D( 

797 dist_mGrid, dist_pGrid, bNext, shk_prbs, perm_shks, tran_shks, LivPrb, NewBornDist 

798): # pragma: nocover 

799 """ 

800 Computes Transition Matrix over normalized market resources and permanent income. 

801 This function is built to non-stochastic simulate the IndShockConsumerType. 

802 

803 Parameters 

804 ---------- 

805 dist_mGrid : np.array 

806 Grid over normalized market resources 

807 

808 dist_pGrid : np.array 

809 Grid over permanent income 

810 

811 bNext : np.array 

812 Grid over bank balances 

813 

814 shk_prbs : np.array 

815 Array of shock probabilities over combinations of perm and tran shocks 

816 

817 perm_shks : np.array 

818 Array of shocks to permanent income 

819 

820 tran_shks : np.array 

821 Array of shocks to transitory income 

822 

823 LivPrb : float 

824 Probability of not dying 

825 

826 NewBornDist : np.array 

827 array representing distribution of newborns across grid of normalized market resources and grid of permanent income. 

828 

829 Returns 

830 ------- 

831 TranMatrix : np.array 

832 Transition Matrix over normalized market resources grid and permanent income grid 

833 """ 

834 TranMatrix = np.zeros( 

835 (len(dist_mGrid) * len(dist_pGrid), len(dist_mGrid) * len(dist_pGrid)) 

836 ) 

837 for i in numba.prange(len(dist_mGrid)): 

838 for j in numba.prange(len(dist_pGrid)): 

839 mNext_ij = ( 

840 bNext[i] / perm_shks + tran_shks 

841 ) # Compute next period's market resources given todays bank balances bnext[i] 

842 pNext_ij = ( 

843 dist_pGrid[j] * perm_shks 

844 ) # Computes next period's permanent income level by applying permanent income shock 

845 TranMatrix[:, i * len(dist_pGrid) + j] += ( 

846 LivPrb 

847 * jump_to_grid_2D(mNext_ij, pNext_ij, shk_prbs, dist_mGrid, dist_pGrid) 

848 + (1.0 - LivPrb) * NewBornDist 

849 ) # this is the transition matrix if given you are unemployed today and unemployed tomorrow so you assume the unemployed consumption policy 

850 return TranMatrix 

851 

852 

853# ============================================================================== 

854# ============== Some basic plotting tools ==================================== 

855# ============================================================================== 

856 

857 

858def plot_funcs( 

859 functions, bottom, top, N=1000, legend_kwds=None, xlabel=None, ylabel=None 

860): 

861 """ 

862 Plots 1D function(s) over a given range. 

863 

864 Parameters 

865 ---------- 

866 functions : [function] or function 

867 A single function, or a list of functions, to be plotted. 

868 bottom : float 

869 The lower limit of the domain to be plotted. 

870 top : float 

871 The upper limit of the domain to be plotted. 

872 N : int 

873 Number of points in the domain to evaluate. 

874 legend_kwds: None or dictionary 

875 If not None, the keyword dictionary to pass to plt.legend 

876 xlabel : None or str 

877 Optional horizontal axis label. 

878 ylabel : None or str 

879 Optional vertical axis label. 

880 

881 Returns 

882 ------- 

883 none 

884 """ 

885 import matplotlib.pyplot as plt 

886 

887 plt.ion() 

888 

889 if type(functions) == list: 

890 function_list = functions 

891 else: 

892 function_list = [functions] 

893 

894 for function in function_list: 

895 x = np.linspace(bottom, top, N, endpoint=True) 

896 y = function(x) 

897 plt.plot(x, y) 

898 plt.xlim([bottom, top]) 

899 if xlabel is not None: 

900 plt.xlabel(xlabel) 

901 if ylabel is not None: 

902 plt.ylabel(ylabel) 

903 if legend_kwds is not None: 

904 plt.legend(**legend_kwds) 

905 plt.show(block=False) 

906 

907 

908def plot_funcs_der( 

909 functions, bottom, top, N=1000, legend_kwds=None, xlabel=None, ylabel=None 

910): 

911 """ 

912 Plots the first derivative of 1D function(s) over a given range. 

913 

914 Parameters 

915 ---------- 

916 function : function 

917 A function or list of functions, the derivatives of which are to be plotted. 

918 bottom : float 

919 The lower limit of the domain to be plotted. 

920 top : float 

921 The upper limit of the domain to be plotted. 

922 N : int 

923 Number of points in the domain to evaluate. 

924 legend_kwds: None or dictionary 

925 If not None, the keyword dictionary to pass to plt.legend 

926 xlabel : None or str 

927 Optional horizontal axis label. 

928 ylabel : None or str 

929 Optional vertical axis label. 

930 

931 Returns 

932 ------- 

933 none 

934 """ 

935 import matplotlib.pyplot as plt 

936 

937 plt.ion() 

938 

939 if type(functions) == list: 

940 function_list = functions 

941 else: 

942 function_list = [functions] 

943 

944 step = (top - bottom) / N 

945 for function in function_list: 

946 x = np.arange(bottom, top, step) 

947 y = function.derivative(x) 

948 plt.plot(x, y) 

949 plt.xlim([bottom, top]) 

950 if xlabel is not None: 

951 plt.xlabel(xlabel) 

952 if ylabel is not None: 

953 plt.ylabel(ylabel) 

954 if legend_kwds is not None: 

955 plt.legend(**legend_kwds) 

956 plt.show(block=False) 

957 

958 

959def plot_func_slices( 

960 func, 

961 bot, 

962 top, 

963 N=1000, 

964 xdim=0, 

965 zdim=1, 

966 zmin=None, 

967 zmax=None, 

968 zn=11, 

969 zorder=1.0, 

970 Z=None, 

971 const=None, 

972 legend_kwds=None, 

973 xlabel=None, 

974 ylabel=None, 

975): 

976 """ 

977 Plots "slices" of a function with more than one argument. User specifies 

978 range of the "x" (horizontal) dimension and selects which other dimension 

979 is "z". Can specify set of z values explicitly with list Z or describe an 

980 exponentially spaced grid with zmin, zmax, (zn, zorder). 

981 

982 Parameters 

983 ---------- 

984 func : callable 

985 The function whose slices are to be plotted. Must take more than one argument. 

986 bot : float 

987 Lowest value in the xdim to plot. 

988 top : float 

989 Highest value in the xdim to plot. 

990 N : int 

991 Number of x values to plot for each slice (default 1000). 

992 xdim : int 

993 Index of the input that serves as "x", the horizontal plot dimension (default 0). 

994 zdim : int 

995 Index of the input that serves as "z", which is varied for each slice plotted (default 1). 

996 zmin : None or float 

997 If specified, the lowest value of z that will be plotted. 

998 zmax : None or float 

999 If specified, the highest value of z that will be plotted. 

1000 zn : int 

1001 The number of slices to plot if zmin and zmax are specified (default 11). 

1002 zorder : float 

1003 The exponential order of the set of z values, if zmin and zmax are specified (default 1.0). 

1004 Z : None or [float] 

1005 A user-specified list (or array) of z values. Cannot be used of zmin and zmax are provided. 

1006 const : None or [float] 

1007 Constant values at which to hold fixed function arguments *other* than x and z. 

1008 E.g. if user wants to plot f(x, 3.0, z, 5.0), they specify xdim=0, zdim=2, const=[3.0, 5.0]. 

1009 legend_kwds: None or dictionary 

1010 If not None, the keyword dictionary to pass to plt.legend 

1011 xlabel : None or str 

1012 Optional horizontal axis label. 

1013 ylabel : None or str 

1014 Optional vertical axis label. 

1015 

1016 Returns 

1017 ------- 

1018 None 

1019 """ 

1020 import matplotlib.pyplot as plt 

1021 

1022 plt.ion() 

1023 

1024 if xdim == zdim: 

1025 raise ValueError("xdim and zdim cannot refer to the same argument!") 

1026 

1027 # Check whether the set for z has been correctly specified 

1028 if (zmin is not None) and (zmax is not None): 

1029 if Z is None: 

1030 if zmax > zmin: 

1031 Z = make_exponential_grid(zmin, zmax, zn, order=zorder) 

1032 else: 

1033 raise ValueError("zmax must be greater than zmin!") 

1034 else: 

1035 raise ValueError( 

1036 "Cannot provide set Z if zmin and zmax are also specified!" 

1037 ) 

1038 if Z is None: 

1039 raise ValueError("Must specify set Z or grid of z with zmin and zmax!") 

1040 

1041 # Build the vectors of x values and constant values 

1042 X = np.linspace(bot, top, num=N) 

1043 if const is not None: 

1044 Q = [const[j] * np.ones(N) for j in range(len(const))] 

1045 else: 

1046 Q = [] 

1047 D = 2 + len(Q) 

1048 

1049 # Assemble a list of function arguments in the right order, leaving z blank 

1050 args = [] 

1051 i = 0 

1052 for d in range(D): 

1053 if d == xdim: 

1054 args.append(X) 

1055 elif d == zdim: 

1056 args.append(None) 

1057 else: 

1058 args.append(Q[i]) 

1059 i += 1 

1060 

1061 # Plot a slice for each z in Z 

1062 for j in range(len(Z)): 

1063 z = Z[j] 

1064 args[zdim] = z * np.ones(N) 

1065 plt.plot(X, func(*args)) 

1066 

1067 # Format and display the figure 

1068 plt.xlim(bot, top) 

1069 if xlabel is not None: 

1070 plt.xlabel(xlabel) 

1071 if ylabel is not None: 

1072 plt.ylabel(ylabel) 

1073 if legend_kwds is not None: 

1074 plt.legend(**legend_kwds) 

1075 plt.show(block=False) 

1076 

1077 

1078############################################################################### 

1079 

1080 

1081def determine_platform(): # pragma: nocover 

1082 """ 

1083 Utility function to return the platform currenlty in use. 

1084 

1085 Returns 

1086 --------- 

1087 pf: str 

1088 'darwin' (MacOS), 'debian'(debian Linux) or 'win' (windows) 

1089 """ 

1090 import platform 

1091 

1092 pform = platform.system().lower() 

1093 if "darwin" in pform: 

1094 pf = "darwin" # MacOS 

1095 elif "debian" in pform: 

1096 pf = "debian" # Probably cloud (MyBinder, CoLab, ...) 

1097 elif "ubuntu" in pform: 

1098 pf = "debian" # Probably cloud (MyBinder, CoLab, ...) 

1099 elif "win" in pform: 

1100 pf = "win" 

1101 elif "linux" in pform: 

1102 pf = "linux" 

1103 else: 

1104 raise ValueError("Not able to find out the platform.") 

1105 return pf 

1106 

1107 

1108def test_latex_installation(pf): # pragma: no cover 

1109 """Test to check if latex is installed on the machine. 

1110 

1111 Parameters 

1112 ----------- 

1113 pf: str (platform) 

1114 output of determine_platform() 

1115 

1116 Returns 

1117 -------- 

1118 bool: Boolean 

1119 True if latex found, else installed in the case of debian 

1120 otherwise ImportError raised to direct user to install latex manually 

1121 """ 

1122 # Test whether latex is installed (some of the figures require it) 

1123 from distutils.spawn import find_executable 

1124 

1125 latexExists = False 

1126 

1127 if find_executable("latex"): 

1128 latexExists = True 

1129 return True 

1130 

1131 if not latexExists: 

1132 print("Some of the figures below require a full installation of LaTeX") 

1133 # If running on Mac or Win, user can be assumed to be able to install 

1134 # any missing packages in response to error messages; but not on cloud 

1135 # so load LaTeX by hand (painfully slowly) 

1136 if "debian" in pf: # CoLab and MyBinder are both ubuntu 

1137 print("Installing LaTeX now; please wait 3-5 minutes") 

1138 from IPython.utils import io 

1139 

1140 with io.capture_output() as captured: # Hide hideously long output 

1141 os.system("apt-get update") 

1142 os.system( 

1143 "apt-get install texlive texlive-latex-extra texlive-xetex dvipng" 

1144 ) 

1145 latexExists = True 

1146 return True 

1147 else: 

1148 raise ImportError( 

1149 "Please install a full distribution of LaTeX on your computer then rerun. \n \ 

1150 A full distribution means textlive, texlive-latex-extras, texlive-xetex, dvipng, and ghostscript" 

1151 ) 

1152 

1153 

1154def in_ipynb(): 

1155 """If the ipython process contains 'terminal' assume not in a notebook. 

1156 

1157 Returns 

1158 -------- 

1159 bool: Boolean 

1160 True if called from a jupyter notebook, else False 

1161 """ 

1162 try: 

1163 if "terminal" in str(type(get_ipython())): 

1164 return False 

1165 else: 

1166 return True 

1167 except NameError: 

1168 return False 

1169 

1170 

1171def setup_latex_env_notebook(pf, latexExists): # pragma: nocover 

1172 """This is needed for use of the latex_envs notebook extension 

1173 which allows the use of environments in Markdown. 

1174 

1175 Parameters 

1176 ----------- 

1177 pf: str (platform) 

1178 output of determine_platform() 

1179 """ 

1180 import os 

1181 

1182 import matplotlib.pyplot as plt 

1183 

1184 plt.rc("font", family="serif") 

1185 plt.rc("text", usetex=latexExists) 

1186 if latexExists: 

1187 latex_preamble = ( 

1188 r"\usepackage{amsmath}\usepackage{amsfonts}" 

1189 r"\usepackage[T1]{fontenc}" 

1190 r"\providecommand{\Ex}{\mathbb{E}}" 

1191 r"\providecommand{\StE}{\check}" 

1192 r"\providecommand{\Trg}{\hat}" 

1193 r"\providecommand{\PermGroFac}{\Gamma}" 

1194 r"\providecommand{\cLev}{\pmb{\mathrm{c}}}" 

1195 r"\providecommand{\mLev}{\pmb{\mathrm{m}}}" 

1196 r"\providecommand{\Rfree}{\mathsf{R}}" 

1197 r"\providecommand{\DiscFac}{\beta}" 

1198 r"\providecommand{\CRRA}{\rho}" 

1199 r"\providecommand{\MPC}{\kappa}" 

1200 r"\providecommand{\UnempPrb}{\wp}" 

1201 ) 

1202 # Latex expects paths to be separated by /. \ might result in pieces 

1203 # being interpreted as commands. 

1204 latexdefs_path = os.getcwd().replace(os.path.sep, "/") + "/latexdefs.tex" 

1205 if os.path.isfile(latexdefs_path): 

1206 latex_preamble = latex_preamble + r"\input{" + latexdefs_path + r"}" 

1207 else: # the required latex_envs package needs this file to exist even if it is empty 

1208 from pathlib import Path 

1209 

1210 Path(latexdefs_path).touch() 

1211 plt.rcParams["text.latex.preamble"] = latex_preamble 

1212 

1213 

1214def make_figs(figure_name, saveFigs, drawFigs, target_dir="Figures"): 

1215 """Utility function to save figure in multiple formats and display the image. 

1216 

1217 Parameters 

1218 ---------- 

1219 figure_name: str 

1220 name of the figure 

1221 saveFigs: bool 

1222 True if the figure needs to be written to disk else False 

1223 drawFigs: bool 

1224 True if the figure should be displayed using plt.draw() 

1225 target_dir: str, default = 'Figures/' 

1226 Name of folder to save figures to in the current directory 

1227 

1228 """ 

1229 import matplotlib.pyplot as plt 

1230 

1231 if saveFigs: 

1232 import os 

1233 

1234 # Where to put any figures that the user wants to save 

1235 my_file_path = os.getcwd() # Find pathname to this file: 

1236 Figures_dir = os.path.join( 

1237 my_file_path, f"{target_dir}" 

1238 ) # LaTeX document assumes figures will be here 

1239 if not os.path.exists(Figures_dir): 

1240 os.makedirs(Figures_dir) # If dir does not exist, create it 

1241 # Save the figures in several formats 

1242 print(f"Saving figure {figure_name} in {target_dir}") 

1243 plt.savefig( 

1244 os.path.join(target_dir, f"{figure_name}.jpg"), 

1245 # metadata is not supported for jpg 

1246 ) # For web/html 

1247 plt.savefig( 

1248 os.path.join(target_dir, f"{figure_name}.png"), 

1249 metadata={"CreationDate": None}, 

1250 ) # For web/html 

1251 plt.savefig( 

1252 os.path.join(target_dir, f"{figure_name}.pdf"), 

1253 metadata={"CreationDate": None}, 

1254 ) # For LaTeX 

1255 plt.savefig( 

1256 os.path.join(target_dir, f"{figure_name}.svg"), 

1257 metadata={"Date": None}, 

1258 ) # For html5 

1259 # Make sure it's possible to plot it by checking for GUI 

1260 if drawFigs and find_gui(): 

1261 plt.ion() # Counterintuitively, you want interactive mode on if you don't want to interact 

1262 plt.draw() # Change to false if you want to pause after the figure 

1263 plt.pause(2) 

1264 

1265 

1266def find_gui(): 

1267 """Quick fix to check if matplotlib is running in a GUI environment. 

1268 

1269 Returns 

1270 ------- 

1271 bool: Boolean 

1272 True if it's a GUI environment, False if not. 

1273 """ 

1274 try: 

1275 import matplotlib.pyplot as plt 

1276 except: 

1277 return False 

1278 if plt.get_backend() == "Agg": 

1279 return False 

1280 return True 

1281 

1282 

1283def benchmark( 

1284 agent, sort_by="tottime", max_print=10, filename="restats", return_output=False 

1285): # pragma: nocover 

1286 """ 

1287 Profiling tool for HARK models. Calling `benchmark` on agents calls the solver for 

1288 the agents and provides time to solve as well as the top `max_print` function calls 

1289 in terms of `sort_by`. Optionally allows for saving a text copy of the profile 

1290 as well as returning the `Stats` object for further inspection. 

1291 

1292 For more details on the python profilers, see 

1293 https://docs.python.org/3/library/profile.html#the-stats-class 

1294 

1295 Parameters 

1296 ---------- 

1297 agent: AgentType 

1298 A HARK AgentType with a solve() method. 

1299 sort_by: string 

1300 A string to sort the stats by. 

1301 max_print: int 

1302 Number of lines to print 

1303 filename: string 

1304 Optional filename to save output. 

1305 return_output: bool 

1306 Boolean to determine whether Stats object should be returned. 

1307 

1308 Returns 

1309 ------- 

1310 stats: Stats (optional) 

1311 Profiling object with call statistics. 

1312 """ 

1313 cProfile.run("agent.solve()", filename) 

1314 stats = pstats.Stats(filename) 

1315 stats.strip_dirs() 

1316 stats.sort_stats(sort_by) 

1317 stats.print_stats(max_print) 

1318 if return_output: 

1319 return stats 

1320 

1321 

1322simpledec = re.compile(r"\d*\.\d{8,20}") 

1323 

1324 

1325def mround(match): 

1326 return f"{float(match.group()):.5f}" 

1327 

1328 

1329def round_in_file(filename): # pragma: nocover 

1330 with open(filename, "r+") as file: 

1331 filetext = file.read() 

1332 filetext = re.sub(simpledec, mround, filetext) 

1333 file.seek(0) 

1334 file.write(filetext) 

1335 file.truncate() 

1336 

1337 

1338def files_in_dir(mypath): 

1339 return [ 

1340 os.path.join(mypath, f) 

1341 for f in os.listdir(mypath) 

1342 if os.path.isfile(os.path.join(mypath, f)) 

1343 ]