11import numpy as np
2- from numpy .random import default_rng
32from math import log
43from scipy .stats import norm
54
5+
6+ # -------- RNG --------
67def make_rng (seed = None ):
7- """Create a reproducible NumPy Generator."""
8+ """Create a reproducible NumPy Generator (PCG64) ."""
89 return np .random .default_rng (seed )
910
11+
12+ # -------- Core Sampler --------
1013def sample (dist , size , rng = None , ** params ):
1114 """
1215 Generic sampler for common distributions.
13- dist in {'normal','lognormal','pareto','gamma','poisson','binomial',
14- 'negbin','exponential','weibull','mvnormal'}
16+
17+ dist in {
18+ 'normal','lognormal','pareto','gamma','poisson','binomial',
19+ 'negbin','exponential','weibull','mvnormal'
20+ }
21+
22+ Note:
23+ - Discrete dists ('poisson','binomial','negbin') return integer dtype.
24+ - 'lognormal' supports `from_quantiles=True` with (x1,p1),(x2,p2).
25+ - 'negbin' expects `mean` (mu) and `k` (dispersion > 0) and uses a
26+ Gamma–Poisson mixture so k may be non-integer.
1527 """
1628 rng = rng or make_rng ()
17- d = dist .lower ()
29+ n = int (size )
30+ if n <= 0 :
31+ raise ValueError ("size must be a positive integer" )
32+ d = str (dist ).lower ()
1833
1934 if d == "normal" :
20- return rng .normal (loc = params ["loc" ], scale = params ["scale" ], size = size )
35+ loc = float (params ["loc" ])
36+ scale = float (params ["scale" ])
37+ if scale <= 0 :
38+ raise ValueError ("normal: scale must be > 0" )
39+ return rng .normal (loc = loc , scale = scale , size = n )
2140
2241 if d == "lognormal" :
2342 if params .get ("from_quantiles" ):
2443 mu , sigma = _lognormal_mu_sigma_from_quantiles (
2544 x1 = params ["x1" ], p1 = params ["p1" ], x2 = params ["x2" ], p2 = params ["p2" ]
2645 )
2746 else :
28- mu , sigma = params ["meanlog" ], params ["sdlog" ]
29- return rng .lognormal (mean = mu , sigma = sigma , size = size )
47+ mu = float (params ["meanlog" ])
48+ sigma = float (params ["sdlog" ])
49+ if sigma <= 0 :
50+ raise ValueError ("lognormal: sdlog must be > 0" )
51+ return rng .lognormal (mean = mu , sigma = sigma , size = n )
3052
3153 if d == "pareto" :
32- a = params ["alpha" ]; xm = params .get ("xm" , 1.0 )
33- return xm * (1.0 + rng .pareto (a , size = size ))
54+ a = float (params ["alpha" ])
55+ if a <= 0 :
56+ raise ValueError ("pareto: alpha must be > 0" )
57+ xm = float (params .get ("xm" , 1.0 ))
58+ # NumPy's pareto is Lomax-1; this yields support [xm, inf)
59+ return xm * (1.0 + rng .pareto (a , size = n ))
3460
3561 if d == "gamma" :
36- return rng .gamma (shape = params ["shape" ], scale = params ["scale" ], size = size )
62+ shape = float (params ["shape" ])
63+ scale = float (params ["scale" ])
64+ if shape <= 0 or scale <= 0 :
65+ raise ValueError ("gamma: shape, scale must be > 0" )
66+ return rng .gamma (shape = shape , scale = scale , size = n )
3767
3868 if d == "poisson" :
39- return rng .poisson (lam = params ["lam" ], size = size )
69+ lam = float (params ["lam" ])
70+ if lam < 0 :
71+ raise ValueError ("poisson: lam must be >= 0" )
72+ # ensure integer dtype (NumPy already returns ints, make explicit)
73+ return rng .poisson (lam = lam , size = n ).astype (np .int64 )
4074
4175 if d == "binomial" :
42- return rng .binomial (n = int (params ["n" ]), p = float (params ["p" ]), size = size )
76+ trials = int (params ["n" ])
77+ p = float (params ["p" ])
78+ if trials < 0 or not (0.0 <= p <= 1.0 ):
79+ raise ValueError ("binomial: n >= 0 and 0 <= p <= 1 required" )
80+ return rng .binomial (n = trials , p = p , size = n ).astype (np .int64 )
4381
4482 if d == "negbin" :
45- mu , k = float (params ["mean" ]), float (params ["k" ])
46- p = k / (k + mu ); n = k
47- return rng .negative_binomial (n = n , p = p , size = size )
83+ # Gamma–Poisson mixture parameterization (robust for non-integer k)
84+ mu = float (params ["mean" ])
85+ k = float (params ["k" ])
86+ if mu < 0 or k <= 0 :
87+ raise ValueError ("negbin: mean >= 0 and k > 0 required" )
88+ lam = rng .gamma (shape = k , scale = mu / k , size = n )
89+ return rng .poisson (lam ).astype (np .int64 )
4890
4991 if d == "exponential" :
5092 lam = float (params ["lam" ])
51- return rng .exponential (scale = 1.0 / lam , size = size )
93+ if lam <= 0 :
94+ raise ValueError ("exponential: lam must be > 0" )
95+ return rng .exponential (scale = 1.0 / lam , size = n )
5296
5397 if d == "weibull" :
54- k = float (params ["k" ]); lam = float (params ["lam" ])
55- return lam * rng .weibull (a = k , size = size )
98+ k = float (params ["k" ])
99+ lam = float (params ["lam" ])
100+ if k <= 0 or lam <= 0 :
101+ raise ValueError ("weibull: k, lam must be > 0" )
102+ # NumPy uses Weibull with scale=1; multiply by scale (lam)
103+ return lam * rng .weibull (a = k , size = n )
56104
57105 if d == "mvnormal" :
58106 mean = np .asarray (params ["mean" ], dtype = float )
59107 cov = np .asarray (params ["cov" ], dtype = float )
60- return rng .multivariate_normal (mean = mean , cov = cov , size = size )
108+ if mean .ndim != 1 or cov .ndim != 2 or cov .shape [0 ] != cov .shape [1 ] or cov .shape [0 ] != mean .size :
109+ raise ValueError ("mvnormal: mean (d,), cov (d,d) required" )
110+ return rng .multivariate_normal (mean = mean , cov = cov , size = n )
111+
112+ raise ValueError (f"Unsupported distribution: { dist !r} " )
61113
62- raise ValueError ("Unsupported distribution: %s" % dist )
63114
115+ # -------- Helpers --------
64116def categorical (probs , size , rng = None , labels = None ):
65117 """Draw from a categorical distribution (optionally return labels)."""
66118 rng = rng or make_rng ()
67- p = np .asarray (probs , dtype = float ); p = p / p .sum ()
68- idx = rng .choice (len (p ), size = size , p = p )
119+ p = np .asarray (probs , dtype = float )
120+ if p .ndim != 1 or p .size == 0 :
121+ raise ValueError ("categorical: probs must be a 1D non-empty array" )
122+ s = p .sum ()
123+ if not np .isfinite (s ) or s <= 0 :
124+ raise ValueError ("categorical: probs must sum to a positive finite value" )
125+ p = p / s
126+ idx = rng .choice (len (p ), size = int (size ), p = p )
69127 if labels is None :
70128 return idx
71129 labels = np .asarray (labels , dtype = object )
130+ if labels .size != p .size :
131+ raise ValueError ("categorical: labels length must match probs" )
72132 return labels [idx ]
73133
134+
74135def dirichlet (alpha , size = 1 , rng = None ):
75- """Draw Dirichlet vectors (size x K)."""
136+ """Draw Dirichlet vectors; returns shape (size, K)."""
76137 rng = rng or make_rng ()
77- return rng .dirichlet (alpha , size = size )
138+ a = np .asarray (alpha , dtype = float )
139+ if (a <= 0 ).any ():
140+ raise ValueError ("dirichlet: all alpha > 0 required" )
141+ return rng .dirichlet (a , size = int (size ))
142+
78143
79144def mixture (components , weights , size , rng = None ):
80145 """
81146 Finite mixture sampler.
82147
83148 components: list of (dist, params) tuples
84- weights: list of floats (sum to 1)
149+ weights: list/array of floats (sum to 1)
150+ Returns float array of length `size`.
85151 """
86152 rng = rng or make_rng ()
87- w = np .asarray (weights , float ); w = w / w .sum ()
88- comp_idx = rng .choice (len (components ), size = size , p = w )
89- out = np .empty (size , dtype = float )
90- for i , (dist , params ) in enumerate (components ):
153+ w = np .asarray (weights , float )
154+ if w .ndim != 1 or w .size != len (components ):
155+ raise ValueError ("mixture: weights length must match components" )
156+ s = w .sum ()
157+ if not np .isfinite (s ) or s <= 0 :
158+ raise ValueError ("mixture: weights must sum to a positive finite value" )
159+ w = w / s
160+
161+ comp_idx = rng .choice (len (components ), size = int (size ), p = w )
162+ out = np .empty (int (size ), dtype = float )
163+ for i , (dname , dparams ) in enumerate (components ):
91164 m = np .sum (comp_idx == i )
92165 if m :
93- out [comp_idx == i ] = sample (dist , m , rng = rng , ** params )
166+ out [comp_idx == i ] = sample (dname , m , rng = rng , ** dparams )
167+ # result may mix ints/floats; keep float for generality
94168 return out
95169
170+
96171def survival_times (dist , size , rng = None , censor_at = None , ** params ):
97172 """
98173 Draw time-to-event data with optional right-censoring.
99174 dist in {'exponential','weibull','lognormal'}
100175 Returns (times, events) where events is 1 for observed, 0 for censored.
101176 """
102177 rng = rng or make_rng ()
103- if dist == "lognormal" :
178+ d = str (dist ).lower ()
179+ if d == "lognormal" :
104180 t = sample ("lognormal" , size , rng = rng , ** params )
105- elif dist == "weibull" :
181+ elif d == "weibull" :
106182 t = sample ("weibull" , size , rng = rng , ** params )
107- elif dist == "exponential" :
183+ elif d == "exponential" :
108184 t = sample ("exponential" , size , rng = rng , ** params )
109185 else :
110186 raise ValueError ("survival_times supports 'lognormal','weibull','exponential'" )
111187
112- events = np .ones (size , dtype = int )
188+ events = np .ones (int ( size ) , dtype = int )
113189 if censor_at is not None :
114- cens = t > censor_at
115- t [cens ] = censor_at
190+ c = float (censor_at )
191+ cens = t > c
192+ t = t .copy ()
193+ t [cens ] = c
116194 events [cens ] = 0
117195 return t , events
118196
197+
198+ # -------- Internal --------
119199def _lognormal_mu_sigma_from_quantiles (x1 , p1 , x2 , p2 ):
120200 """Solve (mu, sigma) of ln(X) ~ N(mu,sigma^2) from two quantiles."""
201+ x1 = float (x1 ); x2 = float (x2 )
202+ p1 = float (p1 ); p2 = float (p2 )
203+ if not (0.0 < p1 < 1.0 and 0.0 < p2 < 1.0 ):
204+ raise ValueError ("lognormal quantiles: p1,p2 must be in (0,1)" )
205+ if not (0.0 < x1 < x2 ):
206+ raise ValueError ("lognormal quantiles: require 0 < x1 < x2" )
207+
121208 z1 = norm .ppf (p1 ); z2 = norm .ppf (p2 )
122- if not (0 < x1 < x2 ):
123- raise ValueError ("Require 0 < x1 < x2 for lognormal quantiles." )
124- sigma = (log (x2 ) - log (x1 )) / (z2 - z1 )
209+ dz = z2 - z1
210+ # New: reject nearly-equal percentiles (ill-conditioned)
211+ if not np .isfinite (z1 ) or not np .isfinite (z2 ) or abs (dz ) < 1e-3 :
212+ raise ValueError ("lognormal quantiles: p1 and p2 too close or invalid" )
213+
214+ sigma = (log (x2 ) - log (x1 )) / dz
215+ # New: sanity bound on sigma to avoid numerical blowups in RNG
216+ if not np .isfinite (sigma ) or sigma <= 0 or sigma > 8.0 :
217+ raise ValueError ("lognormal quantiles: solved sigma invalid or too large" )
125218 mu = log (x1 ) - sigma * z1
126- return float (mu ), float (sigma )
219+ return float (mu ), float (sigma )
0 commit comments