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.
- goad_toolkit/__init__.py +0 -0
- goad_toolkit/analytics.py +325 -0
- goad_toolkit/config.py +18 -0
- goad_toolkit/dataprocessor.py +51 -0
- goad_toolkit/datatransforms.py +184 -0
- goad_toolkit/distributions.py +80 -0
- goad_toolkit/filehandler.py +65 -0
- goad_toolkit/main.py +0 -0
- goad_toolkit/models.py +44 -0
- goad_toolkit/visualizer.py +505 -0
- goad_toolkit-0.1.0.dist-info/METADATA +231 -0
- goad_toolkit-0.1.0.dist-info/RECORD +14 -0
- goad_toolkit-0.1.0.dist-info/WHEEL +4 -0
- goad_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
goad_toolkit/__init__.py
ADDED
|
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
|