Coverage for src/amisc/rv.py: 83%

161 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-29 21:38 +0000

1"""Provides small classes for random variables. 

2 

3Includes: 

4 

5- `BaseRV`: Abstract wrapper class of a random variable 

6- `UniformRV`: a uniformly-distributed random variable 

7- `NormalRV`: a normally-distributed random variable 

8- `ScalarRV`: a stand-in class for a variable with no uncertainty or pdf 

9- `LogUniformRV`: base 10 log-uniform 

10- `LogNormalRV`: base 10 log-normal 

11""" 

12import random 

13import string 

14from abc import ABC, abstractmethod 

15 

16import numpy as np 

17 

18 

19class BaseRV(ABC): 

20 """Small wrapper class similar to `scipy.stats` random variables (RVs). 

21 

22 :ivar id: an identifier for the variable 

23 :ivar bds: the explicit domain bounds of the variable (limits of where you expect to use it) 

24 :ivar nominal: a typical value for this variable (within `bds`) 

25 :ivar tex: latex format for the random variable, i.e. r"$x_i$" 

26 :ivar description: a lengthier description of the variable 

27 :ivar units: assumed units for the variable (if applicable) 

28 :ivar param_type: an additional descriptor for how this rv is used, e.g. calibration, operating, design, etc. 

29 

30 :vartype id: str 

31 :vartype bds: tuple[float, float] 

32 :vartype nominal: float 

33 :vartype tex: str 

34 :vartype description: str 

35 :vartype units: str 

36 :vartype param_type: str 

37 """ 

38 

39 def __init__(self, id='', *, tex='', description='Random variable', units='-', 

40 param_type='calibration', nominal=1, domain=(0, 1)): 

41 """Child classes must implement `sample` and `pdf` methods.""" 

42 self.bds = tuple(domain) 

43 self.nominal = nominal 

44 self.id = id if id != '' else 'X_' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=4)) 

45 self.is_custom_id = id != '' # Whether a custom id was assigned to this variable 

46 self.tex = tex 

47 self.description = description 

48 self.units = units 

49 self.param_type = param_type 

50 

51 def __repr__(self): 

52 return r'{}'.format(f"{self.id} - {self.description} ({self.units})") 

53 

54 def __str__(self): 

55 return self.id 

56 

57 def __eq__(self, other): 

58 """Consider two RVs equal if they share the same string id. 

59 

60 Also returns true when checking if this RV is equal to a string id by itself. 

61 """ 

62 if isinstance(other, BaseRV): 

63 return self.id == other.id 

64 elif isinstance(other, str): 

65 return self.id == other 

66 else: 

67 return NotImplemented 

68 

69 def __hash__(self): 

70 return hash(self.id) 

71 

72 def to_tex(self, units=False, symbol=True): 

73 """Return a raw string that is well-formatted for plotting (with tex). 

74 

75 :param units: whether to include the units in the string 

76 :param symbol: just latex symbol if true, otherwise the full description 

77 """ 

78 s = self.tex if symbol else self.description 

79 if s == '': 

80 s = str(self) 

81 return r'{} [{}]'.format(s, self.units) if units else r'{}'.format(s) 

82 

83 def bounds(self): 

84 """Return a tuple of the defined domain of this RV.""" 

85 return self.bds 

86 

87 def update_bounds(self, lb, ub): 

88 """Update the defined domain of this RV to `(lb, ub)`.""" 

89 self.bds = (lb, ub) 

90 

91 def sample_domain(self, shape: tuple | int) -> np.ndarray: 

92 """Return an array of the given `shape` for random samples over the domain of this RV. 

93 

94 :param shape: the shape of samples to return 

95 :returns samples: random samples over the domain of the random variable 

96 """ 

97 if isinstance(shape, int): 

98 shape = (shape, ) 

99 return np.random.rand(*shape) * (self.bds[1] - self.bds[0]) + self.bds[0] 

100 

101 @abstractmethod 

102 def pdf(self, x: np.ndarray) -> np.ndarray: 

103 """Compute the PDF of the RV at the given `x` locations. 

104 

105 :param x: locations to compute the PDF at 

106 :returns f: the PDF evaluations at `x` 

107 """ 

108 pass 

109 

110 @abstractmethod 

111 def sample(self, shape: tuple | int, nominal: float = None) -> np.ndarray: 

112 """Draw samples from the PDF. 

113 

114 :param shape: the shape of the returned samples 

115 :param nominal: a nominal value to use if applicable (i.e. a center for relative Uniform or Normal) 

116 :returns: samples from the PDF of this random variable 

117 """ 

118 pass 

119 

120 

121class ScalarRV(BaseRV): 

122 """A stand-in variable with no uncertainty/pdf, just scalars.""" 

123 

124 def pdf(self, x): 

125 return np.ones(x.shape) 

126 

127 def sample(self, shape, nominal=None): 

128 if isinstance(shape, int): 

129 shape = (shape, ) 

130 if nominal is not None: 

131 return np.ones(shape)*nominal 

132 else: 

133 return self.sample_domain(shape) 

134 

135 

136class UniformRV(BaseRV): 

137 """A uniformly-distributed random variable. 

138 

139 Can be uniformly distributed in one of three ways: between global bounds, relative within a percent, or relative 

140 within a set absolute tolerance. 

141 

142 :ivar type: specifies the type of uniform distribution, either 'bds', 'pct', or 'tol' as described above 

143 :ivar value: the absolute tolerance or percent uncertainty if type is 'tol' or 'pct' 

144 

145 :vartype type: str 

146 :vartype value: float 

147 """ 

148 

149 def __init__(self, arg1: float, arg2: float | str, id='', **kwargs): 

150 """Construct a uniformly-distributed random variable. 

151 

152 :param arg1: lower bound if specifying U(lb, ub), otherwise a tol or pct if specifying U(+/- tol/pct) 

153 :param arg2: upper bound if specifying U(lb, ub), otherwise a str of either 'tol' or 'pct' 

154 """ 

155 domain = kwargs.get('domain', None) 

156 if isinstance(arg2, str): 

157 self.value = arg1 

158 self.type = arg2 

159 else: 

160 self.value = None 

161 self.type = 'bds' 

162 if self.type == 'bds': 

163 domain = (arg1, arg2) if domain is None else tuple(domain) # This means domain overrides (arg1, arg2) 

164 else: 

165 domain = (0, 1) if domain is None else tuple(domain) 

166 kwargs['domain'] = domain 

167 super().__init__(id, **kwargs) 

168 

169 # Set default nominal value as middle of the domain if not specified 

170 if kwargs.get('nominal', None) is None: 

171 self.nominal = (self.bds[1] + self.bds[0]) / 2 

172 

173 def __str__(self): 

174 return self.id if self.is_custom_id else f'U({self.bds[0]}, {self.bds[1]})' 

175 

176 def get_uniform_bounds(self, nominal: float = None) -> tuple[float, float]: 

177 """Return the correct set of bounds based on type of uniform distribution. 

178 

179 :param nominal: the center value for relative uniform distributions 

180 :returns: the uniform bounds 

181 """ 

182 match self.type: 

183 case 'bds': 

184 return self.bds 

185 case 'pct': 

186 if nominal is None: 

187 return self.bds 

188 return nominal * (1 - self.value), nominal * (1 + self.value) 

189 case 'tol': 

190 if nominal is None: 

191 return self.bds 

192 return nominal - self.value, nominal + self.value 

193 case other: 

194 raise NotImplementedError(f'self.type = {other} not known. Choose from ["pct, "tol", "bds"]') 

195 

196 def pdf(self, x: np.ndarray, nominal: float = None) -> np.ndarray: 

197 """Compute the pdf for a uniform distribution. 

198 

199 :param x: locations to compute the pdf at 

200 :param nominal: center location for relative uniform rvs 

201 :returns: the evaluated PDF at `x` 

202 """ 

203 x = np.atleast_1d(x) 

204 bds = self.get_uniform_bounds(nominal) 

205 den = bds[1] - bds[0] 

206 den = 1 if np.isclose(den, 0) else den 

207 y = np.broadcast_to(1 / den, x.shape).copy() 

208 y[np.where(x > bds[1])] = 0 

209 y[np.where(x < bds[0])] = 0 

210 return y 

211 

212 def sample(self, shape, nominal=None): 

213 if isinstance(shape, int): 

214 shape = (shape, ) 

215 bds = self.get_uniform_bounds(nominal) 

216 return np.random.rand(*shape) * (bds[1] - bds[0]) + bds[0] 

217 

218 

219class LogUniformRV(BaseRV): 

220 """A base 10 log-uniform distributed random variable, only supports absolute bounds.""" 

221 

222 def __init__(self, log10_a: float, log10_b: float, id='', **kwargs): 

223 """Construct the log-uniform random variable. 

224 

225 :param log10_a: the lower bound in log10 space 

226 :param log10_b: the upper bound in log10 space 

227 """ 

228 super().__init__(id, **kwargs) 

229 self.bds = (10**log10_a, 10**log10_b) 

230 

231 def __str__(self): 

232 return self.id if self.is_custom_id else f'LU({np.log10(self.bds[0]): .2f}, {np.log10(self.bds[1]): .2f})' 

233 

234 def pdf(self, x): 

235 return np.log10(np.e) / (x * (np.log10(self.bds[1]) - np.log10(self.bds[0]))) 

236 

237 def sample(self, shape, nominal=None): 

238 if isinstance(shape, int): 

239 shape = (shape, ) 

240 lb = np.log10(self.bds[0]) 

241 ub = np.log10(self.bds[1]) 

242 return 10 ** (np.random.rand(*shape) * (ub - lb) + lb) 

243 

244 

245class LogNormalRV(BaseRV): 

246 """A base 10 log-normal distributed random variable. 

247 

248 :ivar mu: the center of the log-normal distribution 

249 :ivar std: the standard deviation of the log-normal distribution 

250 

251 :vartype mu: float 

252 :vartype std: float 

253 """ 

254 

255 def __init__(self, mu: float, std: float, id='', **kwargs): 

256 """Construct the RV with the mean and std of the underlying distribution, 

257 i.e. $\\log_{10}(x) \\sim N(\\mu, \\sigma)$. 

258 

259 :param mu: the center of the log-normal distribution 

260 :param std: the standard deviation of the log-normal distribution 

261 """ 

262 domain = kwargs.get('domain', None) 

263 if domain is None: 

264 domain = (10 ** (mu - 3*std), 10 ** (mu + 3*std)) # Use a default domain of +- 3std 

265 kwargs['domain'] = domain 

266 super().__init__(id, **kwargs) 

267 self.std = std 

268 self.mu = mu 

269 

270 def recenter(self, mu: float, std: float = None): 

271 """Move the center of the distribution to `mu` with standard deviation `std` (optional) 

272 

273 :param mu: the new center of the distribution 

274 :param std: (optional) new standard deviation 

275 """ 

276 self.mu = mu 

277 if std is not None: 

278 self.std = std 

279 

280 def __str__(self): 

281 return self.id if self.is_custom_id else f'LN_10({self.mu}, {self.std})' 

282 

283 def pdf(self, x): 

284 return (np.log10(np.e) / (x * self.std * np.sqrt(2 * np.pi))) * \ 

285 np.exp(-0.5 * ((np.log10(x) - self.mu) / self.std) ** 2) 

286 

287 def sample(self, shape, nominal=None): 

288 if isinstance(shape, int): 

289 shape = (shape, ) 

290 scale = np.log10(np.e) 

291 center = self.mu if nominal is None else nominal 

292 return np.random.lognormal(mean=(1 / scale) * center, sigma=(1 / scale) * self.std, size=shape) 

293 # return 10 ** (np.random.randn(*size)*self.std + center) # Alternatively 

294 

295 

296class NormalRV(BaseRV): 

297 """A normally-distributed random variable. 

298 

299 :ivar mu: float, the mean of the normal distribution 

300 :ivar std: float, the standard deviation of the normal distribution 

301 

302 :vartype mu: float 

303 :vartype std: float 

304 """ 

305 

306 def __init__(self, mu, std, id='', **kwargs): 

307 domain = kwargs.get('domain', None) 

308 if domain is None: 

309 domain = (mu - 2.5*std, mu + 2.5*std) # Use a default domain of +- 2.5std 

310 kwargs['domain'] = domain 

311 super().__init__(id, **kwargs) 

312 self.mu = mu 

313 self.std = std 

314 

315 # Set default nominal value as the provided mean 

316 if kwargs.get('nominal', None) is None: 

317 self.nominal = mu 

318 

319 def recenter(self, mu: float, std: float =None): 

320 """Move the center of the distribution to `mu` with standard deviation `std` (optional) 

321 

322 :param mu: the new center of the distribution 

323 :param std: (optional) new standard deviation 

324 """ 

325 self.mu = mu 

326 if std is not None: 

327 self.std = std 

328 

329 def __str__(self): 

330 return self.id if self.is_custom_id else f'N({self.mu}, {self.std})' 

331 

332 def pdf(self, x): 

333 x = np.atleast_1d(x) 

334 return (1 / (np.sqrt(2 * np.pi) * self.std)) * np.exp(-0.5 * ((x - self.mu) / self.std) ** 2) 

335 

336 def sample(self, shape, nominal=None): 

337 if isinstance(shape, int): 

338 shape = (shape, ) 

339 center = self.mu if nominal is None else nominal 

340 return np.random.randn(*shape) * self.std + center