goad-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,325 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, List, Optional, Tuple, Union
3
+
4
+ import numpy as np
5
+ from loguru import logger
6
+ from scipy import stats
7
+
8
+ from goad_toolkit.distributions import DistributionRegistry
9
+
10
+
11
+ @dataclass
12
+ class KSTestResult:
13
+ """Result of a Kolmogorov-Smirnov test."""
14
+
15
+ statistic: float
16
+ p_value: float
17
+ error: Optional[str] = None
18
+
19
+
20
+ @dataclass
21
+ class FitResult:
22
+ """Result of a successful distribution fit."""
23
+
24
+ distribution: str # Distribution name
25
+ dist_object: Any # The actual distribution object
26
+ params: Tuple[float, ...] # Fitted parameters
27
+ frozen_dist: Any # Frozen distribution with parameters applied
28
+ success: bool = True
29
+ message: str = "Optimization successful"
30
+ log_likelihood: Optional[float] = None
31
+ kstest: Optional[KSTestResult] = None
32
+ best_likelihood: Optional[bool] = (
33
+ None # Indicates if this is the best fit by likelihood
34
+ )
35
+ best_ks: Optional[bool] = None # Indicates if this is the best fit by KS test
36
+
37
+ def __str__(self) -> str:
38
+ """String representation focused on distribution and parameters."""
39
+ return f"{self.distribution}: params={self.params}"
40
+
41
+ def __repr__(self) -> str:
42
+ """Detailed representation including fit quality metrics."""
43
+ ks_info = f", ks_pvalue={self.kstest.p_value:.4f}" if self.kstest else ""
44
+ ll_info = (
45
+ f", loglik={self.log_likelihood:.2f}"
46
+ if self.log_likelihood is not None
47
+ else ""
48
+ )
49
+ best_info = ""
50
+ if self.best_likelihood:
51
+ best_info += " (Best Likelihood)"
52
+ if self.best_ks:
53
+ best_info += " (Best KS)"
54
+
55
+ return f"Fit({self.distribution}: params={self.params}{ll_info}{ks_info}{best_info})"
56
+
57
+
58
+ @dataclass
59
+ class FailedFit:
60
+ """Result of a failed distribution fit."""
61
+
62
+ distribution: str
63
+ message: str
64
+ success: bool = False
65
+
66
+ def __str__(self) -> str:
67
+ """String representation with error message."""
68
+ return f"Failed {self.distribution}: {self.message}"
69
+
70
+
71
+ # Type alias for either success or failure
72
+ Result = Union[FitResult, FailedFit]
73
+
74
+
75
+ class DistributionFitter:
76
+ """Class to fit distributions from a registry to data."""
77
+
78
+ def __init__(self) -> None:
79
+ """Initialize with a distribution registry."""
80
+ self._registry = DistributionRegistry()
81
+
82
+ @property
83
+ def registry(self) -> list[str]:
84
+ return self._registry.get_names()
85
+
86
+ def _get_bounds(self, data: np.ndarray, dist_obj) -> list[tuple]:
87
+ """Generate parameter bounds estimates based on data characteristics."""
88
+ # Calculate statistics for bounds
89
+ data_min = np.min(data)
90
+ data_max = np.max(data)
91
+ data_mean = np.mean(data)
92
+ data_std = np.std(data)
93
+ data_range = data_max - data_min
94
+
95
+ # Universal parameter bounds
96
+ loc_bound = (data_min - data_range, data_max + data_range)
97
+ std_lower_bound = float(data_std / 100)
98
+ scale_bound = (max(std_lower_bound, 0.001), data_std * 20)
99
+ shape_bound = (0.01, 10.0)
100
+
101
+ # Create bounds based on parameter count
102
+ if dist_obj.num_params == 1:
103
+ if dist_obj.is_discrete:
104
+ mean_lower_bound = max(0.1, float(data_mean / 10))
105
+ mean_upper_bound = float(data_mean * 10)
106
+ return [(mean_lower_bound, mean_upper_bound)]
107
+ else:
108
+ return [scale_bound]
109
+ elif dist_obj.num_params == 2:
110
+ return [loc_bound, scale_bound]
111
+ elif dist_obj.num_params == 3:
112
+ return [shape_bound, loc_bound, scale_bound]
113
+ else:
114
+ return [shape_bound] * (dist_obj.num_params - 2) + [loc_bound, scale_bound]
115
+
116
+ def _perform_kstest(self, data: np.ndarray, dist_obj, params) -> KSTestResult:
117
+ """Perform Kolmogorov-Smirnov test for goodness-of-fit."""
118
+ try:
119
+ # Create a frozen distribution with fitted parameters
120
+ fitted_dist = dist_obj.dist(*params)
121
+
122
+ # Run KS test - comparing data with the fitted distribution
123
+ ks_statistic, p_value = stats.kstest(data, fitted_dist.cdf)
124
+
125
+ return KSTestResult(statistic=ks_statistic, p_value=p_value)
126
+ except Exception as e:
127
+ return KSTestResult(
128
+ statistic=float("nan"), p_value=float("nan"), error=str(e)
129
+ )
130
+
131
+ def _calculate_loglikelihood(self, data: np.ndarray, dist_obj, params) -> float:
132
+ """Calculate log-likelihood of data given distribution and parameters.
133
+ For every datapoint, calculate the log probability density function (PDF)
134
+ We sum all the log PDFs to get the log-likelihood of the data.
135
+ If there are a lot of datapoints with very low probability, the log-likelihood
136
+ will be very negative (or -inf if probability is 0).
137
+
138
+ We will prefer distributions with higher log-likelihood values.
139
+ """
140
+ try:
141
+ return np.sum(dist_obj.dist.logpdf(data, *params))
142
+ except Exception as e:
143
+ logger.warning(f"Log-likelihood calculation failed: {str(e)}")
144
+ return -np.inf
145
+
146
+ def fit_distribution(
147
+ self, dist_name: str, data: np.ndarray, method: str = "mle"
148
+ ) -> Result:
149
+ """
150
+ Fit a specific distribution to data.
151
+
152
+ Args:
153
+ dist_name: Name of the distribution to fit
154
+ data: Data to fit the distribution to
155
+ method: Fitting method (default: 'mle')
156
+
157
+ Returns:
158
+ FitResult or FailedFit
159
+ """
160
+ try:
161
+ dist_obj = self._registry.get_distribution(dist_name)
162
+
163
+ # Get parameter bounds
164
+ bounds = self._get_bounds(data, dist_obj)
165
+ # logger.info(f"Bounds for {dist_name}: {bounds}")
166
+
167
+ # Perform the fit
168
+ result = stats.fit(dist_obj.dist, data, method=method, bounds=tuple(bounds))
169
+
170
+ # If fit was not successful, return failure
171
+ if not result.success:
172
+ logger.warning(f"Fitting failed: {result.message}")
173
+ return FailedFit(distribution=dist_name, message=str(result.message))
174
+
175
+ # Create frozen distribution with fitted parameters
176
+ frozen_dist = dist_obj.dist(*result.params)
177
+
178
+ # Run goodness-of-fit tests
179
+ kstest_result = self._perform_kstest(data, dist_obj, result.params)
180
+ log_likelihood = self._calculate_loglikelihood(
181
+ data, dist_obj, result.params
182
+ )
183
+
184
+ return FitResult(
185
+ distribution=dist_name,
186
+ dist_object=dist_obj.dist,
187
+ params=result.params,
188
+ frozen_dist=frozen_dist,
189
+ message=str(result.message),
190
+ log_likelihood=log_likelihood,
191
+ kstest=kstest_result,
192
+ )
193
+
194
+ except Exception as e:
195
+ logger.warning(f"Fitting failed: {str(e)}")
196
+ return FailedFit(
197
+ distribution=dist_name, message=f"Fitting failed: {str(e)}"
198
+ )
199
+
200
+ def _mark_best_fits(self, fits: List[Result], criterion: str) -> List[Result]:
201
+ """
202
+ Mark the best fits in a list of fit results.
203
+
204
+ Args:
205
+ fits: List of fit results to analyze
206
+ criterion: Selection criterion ('likelihood', 'ks', or 'combined')
207
+
208
+ Returns:
209
+ The same list with best_likelihood and best_ks attributes updated for successful fits
210
+ """
211
+ # Find best likelihood fit
212
+ best_likelihood_value = float("-inf")
213
+ best_likelihood_fit = None
214
+
215
+ # Find best KS test fit
216
+ best_ks_value = 0
217
+ best_ks_fit = None
218
+
219
+ # Find the best fits without creating a filtered list
220
+ for fit in fits:
221
+ if isinstance(fit, FitResult):
222
+ # Check for best likelihood
223
+ fit_likelihood = (
224
+ fit.log_likelihood
225
+ if fit.log_likelihood is not None
226
+ else float("-inf")
227
+ )
228
+ if fit_likelihood > best_likelihood_value:
229
+ best_likelihood_value = fit_likelihood
230
+ best_likelihood_fit = fit
231
+
232
+ # Check for best KS test
233
+ fit_ks = fit.kstest.p_value if fit.kstest else 0
234
+ if fit_ks > best_ks_value:
235
+ best_ks_value = fit_ks
236
+ best_ks_fit = fit
237
+
238
+ # Only mark if we found best fits
239
+ if best_likelihood_fit is not None and best_ks_fit is not None:
240
+ # Mark based on criterion
241
+ if criterion == "likelihood":
242
+ # Mark only likelihood best
243
+ for fit in fits:
244
+ if isinstance(fit, FitResult):
245
+ fit.best_likelihood = (
246
+ fit.distribution == best_likelihood_fit.distribution
247
+ )
248
+
249
+ elif criterion == "ks":
250
+ # Mark only KS best
251
+ for fit in fits:
252
+ if isinstance(fit, FitResult):
253
+ fit.best_ks = fit.distribution == best_ks_fit.distribution
254
+
255
+ elif criterion == "combined":
256
+ # Mark both
257
+ for fit in fits:
258
+ if isinstance(fit, FitResult):
259
+ fit.best_likelihood = (
260
+ fit.distribution == best_likelihood_fit.distribution
261
+ )
262
+ fit.best_ks = fit.distribution == best_ks_fit.distribution
263
+ else:
264
+ raise ValueError(f"Unknown criterion '{criterion}'")
265
+
266
+ # Return the original list
267
+ return fits
268
+
269
+ def fit(
270
+ self,
271
+ data: np.ndarray,
272
+ discrete: bool,
273
+ method: str = "mle",
274
+ criterion: str = "combined",
275
+ ) -> list[Result]:
276
+ """
277
+ Fit all registered distributions to data, mark the best fits, and return results.
278
+
279
+ Args:
280
+ data: Data to fit the distribution to
281
+ discrete: Whether to fit discrete (True) or continuous (False) distributions
282
+ method: Fitting method (default: 'mle')
283
+ criterion: Selection criterion ('likelihood', 'ks', or 'combined') for marking best fits
284
+
285
+ Returns:
286
+ List of Result objects (either FitResult or FailedFit) with best fits marked
287
+ """
288
+ if criterion not in ["likelihood", "ks", "combined"]:
289
+ raise ValueError(f"Unknown criterion '{criterion}'")
290
+
291
+ results = []
292
+ for dist_name in self._registry.get_names():
293
+ dist_obj = self._registry.get_distribution(dist_name)
294
+
295
+ if discrete and dist_obj.is_discrete:
296
+ results.append(self.fit_distribution(dist_name, data, method))
297
+
298
+ if not discrete and not dist_obj.is_discrete:
299
+ results.append(self.fit_distribution(dist_name, data, method))
300
+
301
+ # Mark the best fits based on criterion
302
+ self._mark_best_fits(results, criterion)
303
+
304
+ return results
305
+
306
+ @staticmethod
307
+ def best(results: list[Result], criterion: str = "combined") -> list[Result]:
308
+ if criterion == "ks":
309
+ return [
310
+ fit for fit in results if isinstance(fit, FitResult) and fit.best_ks
311
+ ]
312
+ elif criterion == "likelihood":
313
+ return [
314
+ fit
315
+ for fit in results
316
+ if isinstance(fit, FitResult) and fit.best_likelihood
317
+ ]
318
+ elif criterion == "combined":
319
+ return [
320
+ fit
321
+ for fit in results
322
+ if isinstance(fit, FitResult) and (fit.best_likelihood or fit.best_ks)
323
+ ]
324
+ else:
325
+ raise ValueError(f"Unknown criterion '{criterion}'")
goad_toolkit/config.py ADDED
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class FileConfig(BaseModel):
7
+ data_dir: Path = Path.home() / ".cache/mads_datasets/covid"
8
+ filename: Path = Path("covid.csv")
9
+ url: str = (
10
+ "https://raw.githubusercontent.com/mzelst/covid-19/master/data/rivm_by_day.csv"
11
+ )
12
+
13
+
14
+ class DataConfig(BaseModel):
15
+ period: int = -14
16
+ window: int = 7
17
+ start_date: str = "2020-10-01"
18
+ end_date: str = "2021-06-01"
@@ -0,0 +1,51 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ import pandas as pd
6
+
7
+ from goad_toolkit.config import DataConfig, FileConfig
8
+ from goad_toolkit.datatransforms import (
9
+ DiffValues,
10
+ Pipeline,
11
+ RollingAvg,
12
+ SelectDataRange,
13
+ ShiftValues,
14
+ ZScaler,
15
+ )
16
+ from goad_toolkit.filehandler import FileHandler
17
+
18
+
19
+ class DataProcessor(ABC):
20
+ def __init__(self, fileconfig: FileConfig, dataconfig: DataConfig) -> None:
21
+ self.pipeline = Pipeline()
22
+ self.filehandler = FileHandler(fileconfig)
23
+ self.config_pipeline(dataconfig)
24
+
25
+ @abstractmethod
26
+ def config_pipeline(self, dataconfig: DataConfig) -> None:
27
+ pass
28
+
29
+ def process(
30
+ self, filename: Optional[Path] = None, raw: bool = True
31
+ ) -> pd.DataFrame:
32
+ df = self.filehandler.load(filename, raw)
33
+ result = self.pipeline.apply(df)
34
+ return result
35
+
36
+
37
+ class CovidDataProcessor(DataProcessor):
38
+ def config_pipeline(self, dataconfig: DataConfig) -> None:
39
+ self.pipeline.add(DiffValues, column="deaths")
40
+ self.pipeline.add(
41
+ ShiftValues, column="deaths", period=dataconfig.period, rename=True
42
+ )
43
+ self.pipeline.add(
44
+ SelectDataRange,
45
+ start_date=dataconfig.start_date,
46
+ end_date=dataconfig.end_date,
47
+ )
48
+ self.pipeline.add(RollingAvg, column="deaths_shifted", window=dataconfig.window)
49
+ self.pipeline.add(RollingAvg, column="positivetests", window=dataconfig.window)
50
+ self.pipeline.add(ZScaler, column="deaths_shifted", rename=True)
51
+ self.pipeline.add(ZScaler, column="positivetests", rename=True)
@@ -0,0 +1,184 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, Optional, Type, TypeVar
3
+
4
+ import pandas as pd
5
+ from tqdm import tqdm
6
+
7
+ T = TypeVar("T", bound="TransformBase")
8
+
9
+
10
+ class TransformBase(ABC):
11
+ """Base class for all data transformations."""
12
+
13
+ def __init__(self, name: Optional[str] = None, **kwargs):
14
+ self._params = kwargs
15
+ self.name = name or self.__class__.__name__
16
+
17
+ def __call__(self, data: pd.DataFrame) -> pd.DataFrame:
18
+ """Checks columns, passes params to transform"""
19
+ self._validate_column(data)
20
+
21
+ # Call the transform method with unpacked parameters
22
+ return self.transform(data, **self._params)
23
+
24
+ def _validate_column(self, data: pd.DataFrame) -> None:
25
+ """Check if the column exists in the dataframe."""
26
+ column = self._params.get("column")
27
+ if column and column not in data.columns:
28
+ raise ValueError(f"Column '{column}' does not exist in the dataframe.")
29
+
30
+ @abstractmethod
31
+ def transform(self, data: pd.DataFrame, *args, **kwargs) -> pd.DataFrame:
32
+ """Transform the data."""
33
+ pass
34
+
35
+ def get_params(self) -> Dict[str, Any]:
36
+ """Get the current parameters."""
37
+ return self._params.copy()
38
+
39
+ def update_params(self, **kwargs) -> None:
40
+ """Update the parameters."""
41
+ self._params.update(kwargs)
42
+
43
+ def __repr__(self) -> str:
44
+ """String representation of the transform."""
45
+ params_str = ", ".join(f"{k}={v!r}" for k, v in self._params.items())
46
+ return f"{self.name}({params_str})"
47
+
48
+
49
+ class ShiftValues(TransformBase):
50
+ """Shift values in a column by a specified period."""
51
+
52
+ def transform(
53
+ self,
54
+ data: pd.DataFrame,
55
+ column: str,
56
+ period: int,
57
+ rename: Optional[bool] = False,
58
+ ) -> pd.DataFrame:
59
+ """Shift values in a column by a specified period."""
60
+ if rename:
61
+ colname = f"{column}_shifted"
62
+ else:
63
+ colname = column
64
+ data[colname] = data[column].shift(period)
65
+ return data
66
+
67
+
68
+ class DiffValues(TransformBase):
69
+ """Calculate the difference between consecutive values in a column."""
70
+
71
+ def transform(
72
+ self, data: pd.DataFrame, column: str, rename: bool = False
73
+ ) -> pd.DataFrame:
74
+ """Calculate the difference between consecutive values in a column."""
75
+ if rename:
76
+ colname = f"{column}_diff"
77
+ else:
78
+ colname = column
79
+ data[colname] = data[column].diff()
80
+ data.iloc[0, data.columns.get_loc(colname)] = 0
81
+ return data
82
+
83
+
84
+ class SelectDataRange(TransformBase):
85
+ """Select rows within a specified date range."""
86
+
87
+ def transform(
88
+ self, data: pd.DataFrame, start_date: str, end_date: str
89
+ ) -> pd.DataFrame:
90
+ """Select rows within a specified date range."""
91
+ return data.loc[start_date:end_date]
92
+
93
+
94
+ class RollingAvg(TransformBase):
95
+ """Calculate the rolling average of a column."""
96
+
97
+ def transform(
98
+ self, data: pd.DataFrame, column: str, window: int, rename: bool = False
99
+ ) -> pd.DataFrame:
100
+ """Calculate the rolling average of a column."""
101
+ if rename:
102
+ colname = f"{column}_rolling_avg"
103
+ else:
104
+ colname = column
105
+ data[colname] = data[column].rolling(window).mean()
106
+ data.dropna(subset=[colname], inplace=True)
107
+ return data
108
+
109
+
110
+ class ZScaler(TransformBase):
111
+ """Standardize the values in a column."""
112
+
113
+ def transform(
114
+ self, data: pd.DataFrame, column: str, rename: bool = False
115
+ ) -> pd.DataFrame:
116
+ """Standardize the values in a column."""
117
+ if rename:
118
+ colname = f"{column}_zscore"
119
+ else:
120
+ colname = column
121
+ data[colname] = (data[column] - data[column].mean()) / data[column].std()
122
+ return data
123
+
124
+
125
+ class Pipeline:
126
+ """Pipeline for chaining data transformations."""
127
+
128
+ def __init__(self):
129
+ self.transforms: Dict[str, Dict[str, Any]] = {}
130
+
131
+ def add(
132
+ self, transform_class: Type[T], name: Optional[str] = None, **kwargs
133
+ ) -> "Pipeline":
134
+ """Add a transformation to the pipeline without instantiating it yet."""
135
+ # Generate name if not provided
136
+ if name is None:
137
+ name = transform_class.__name__
138
+ counter = 1
139
+ while name in self.transforms:
140
+ name = f"{name}_{counter}"
141
+ counter += 1
142
+
143
+ self.transforms[name] = {"class": transform_class, "params": kwargs}
144
+ return self # Allow method chaining
145
+
146
+ def apply(self, data: pd.DataFrame) -> pd.DataFrame:
147
+ """Apply all transformations in the pipeline."""
148
+ # Make a single copy at the pipeline level
149
+ result = data.copy() if len(self.transforms) > 0 else data
150
+ for name, transform_config in tqdm(
151
+ self.transforms.items(), desc="Applying transforms"
152
+ ):
153
+ transform_class = transform_config["class"]
154
+ params = transform_config["params"]
155
+ transform = transform_class(name=name, **params)
156
+ result = transform(result)
157
+ return result
158
+
159
+ def __getitem__(self, key: str) -> Dict[str, Any]:
160
+ """Get a specific transform configuration by name."""
161
+ if key in self.transforms:
162
+ return self.transforms[key].copy()
163
+ raise KeyError(f"Transform '{key}' not found in pipeline")
164
+
165
+ def __setitem__(self, key: str, params: Dict[str, Any]) -> None:
166
+ """Update parameters for a transform using dictionary-style assignment."""
167
+ if key in self.transforms:
168
+ self.transforms[key]["params"].update(params)
169
+ else:
170
+ raise KeyError(f"Transform '{key}' not found in pipeline")
171
+
172
+ def __repr__(self) -> str:
173
+ """String representation of the pipeline."""
174
+ if not self.transforms:
175
+ return "Pipeline(steps=[])"
176
+
177
+ steps = []
178
+ for name, config in self.transforms.items():
179
+ cls = config["class"].__name__
180
+ params = ", ".join(f"{k}={v!r}" for k, v in config["params"].items())
181
+ steps.append(f" {name}: {cls}({params})")
182
+
183
+ steps_str = ",\n".join(steps)
184
+ return f"Pipeline(\n{steps_str}\n)"
@@ -0,0 +1,80 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict, List
3
+
4
+ from scipy import stats
5
+
6
+
7
+ @dataclass
8
+ class Distribution:
9
+ """Dataclass to hold information about a statistical distribution."""
10
+
11
+ name: str
12
+ dist: Any # The scipy.stats distribution object
13
+ is_discrete: bool
14
+ num_params: int
15
+
16
+ def __str__(self):
17
+ """String representation of the distribution."""
18
+ return f"Distribution({self.name},discrete={self.is_discrete},params={self.num_params})"
19
+
20
+ def __repr__(self):
21
+ """Detailed representation of the distribution."""
22
+ return f"Distribution(name='{self.name}',\n dist={self.dist.__class__.__name__},\n is_discrete={self.is_discrete},\n num_params={self.num_params})"
23
+
24
+
25
+ @dataclass
26
+ class DistributionRegistry:
27
+ """Registry for statistical distributions with metadata."""
28
+
29
+ distributions: Dict[str, Distribution] = field(default_factory=dict)
30
+
31
+ def __post_init__(self):
32
+ """Initialize the registry with common distributions."""
33
+ self.register_distribution("norm", stats.norm, is_discrete=False, num_params=2)
34
+ self.register_distribution(
35
+ "uniform", stats.uniform, is_discrete=False, num_params=2
36
+ )
37
+ self.register_distribution(
38
+ "lognorm", stats.lognorm, is_discrete=False, num_params=3
39
+ )
40
+ self.register_distribution(
41
+ "poisson", stats.poisson, is_discrete=True, num_params=1
42
+ )
43
+ self.register_distribution(
44
+ "exponential", stats.expon, is_discrete=False, num_params=2
45
+ )
46
+ self.register_distribution(
47
+ "skewnorm", stats.skewnorm, is_discrete=False, num_params=3
48
+ )
49
+ self.register_distribution(
50
+ "gamma", stats.gamma, is_discrete=False, num_params=3
51
+ )
52
+ self.register_distribution(
53
+ "weibull", stats.weibull_min, is_discrete=False, num_params=3
54
+ )
55
+
56
+ def __repr__(self) -> str:
57
+ """Detailed representation of the registry."""
58
+ return f"DistributionRegistry({self.get_names()})"
59
+
60
+ def register_distribution(
61
+ self, name: str, dist, is_discrete: bool, num_params: int
62
+ ):
63
+ """Register a new distribution with metadata."""
64
+ self.distributions[name] = Distribution(
65
+ name=name, dist=dist, is_discrete=is_discrete, num_params=num_params
66
+ )
67
+
68
+ def get_distribution(self, name: str) -> Distribution:
69
+ """Get a distribution by name."""
70
+ if name not in self.distributions:
71
+ raise ValueError(f"Distribution '{name}' not found in registry.")
72
+ return self.distributions[name]
73
+
74
+ def get_names(self) -> List[str]:
75
+ """Get all registered distribution names."""
76
+ return list(self.distributions.keys())
77
+
78
+ def is_discrete(self, name: str) -> bool:
79
+ """Check if a distribution is discrete."""
80
+ return self.get_distribution(name).is_discrete