Extending the Library
epftoolbox2 is designed to be extensible. Create custom sources, transformers, validators, models, evaluators, and exporters.
Custom DataSource
from epftoolbox2.data.sources.base import DataSourceimport pandas as pdfrom typing import Optional
class MyApiSource(DataSource): def __init__(self, api_url: str, api_key: str): self.api_url = api_url self.api_key = api_key self._validate_config()
def _validate_config(self) -> bool: if not self.api_key: raise ValueError("API key is required") return True
def fetch(self, start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame: # Convert timestamps to UTC start = start.tz_convert("UTC") if start.tzinfo else start.tz_localize("UTC") end = end.tz_convert("UTC") if end.tzinfo else end.tz_localize("UTC")
# Fetch data from API # ... your implementation ...
return df # UTC DatetimeIndex covering at least [start, end)
def get_cache_config(self) -> Optional[dict]: # Return dict for caching, or None to disable return { "source_type": "my_api", "api_url": self.api_url, "schema_version": 1, # bump when parsing changes, so cached frames are rebuilt }get_cache_config() must contain every parameter that changes the output (not the API key). The pipeline drops
rows outside [start, end), and a fetch for a sub-range must give the same values as a fetch for a larger range.
Otherwise a run assembled from cached pieces differs from a fresh one.
If some of the rows you return are placeholders or provisional for longer than the cache’s two unsettled days (for example NaN for a period the provider has not published yet), say so on the frame:
df.attrs["settled_until"] = pd.Timestamp("2026-09-01", tz="UTC") # rows at or after this are not settledThe cache then does not record those rows as covered and fetches them again next time, while the settled rows
before the timestamp come from disk. Leave it out when every row is settled. A naive timestamp is taken to be UTC.
The value may also be an ISO 8601 string (timestamp.isoformat()), which you need if the frame is saved with
to_parquet, because pandas writes attrs as JSON. See Data Caching.
Custom Transformer
from epftoolbox2.data.transformers.base import Transformerimport pandas as pd
class RollingMeanTransformer(Transformer): def __init__(self, columns: list, window: int = 24): self.columns = columns self.window = window
def transform(self, df: pd.DataFrame) -> pd.DataFrame: result = df.copy() for col in self.columns: result[f"{col}_rolling_mean_{self.window}"] = df[col].rolling(self.window).mean() return resultCustom Validator
from epftoolbox2.data.validators.base import Validatorfrom epftoolbox2.data.validators.result import ValidationResultimport pandas as pd
class OutlierValidator(Validator): def __init__(self, columns: list, threshold: float = 3.0): self.columns = columns self.threshold = threshold
def validate(self, df: pd.DataFrame) -> ValidationResult: result = ValidationResult()
for col in self.columns: if col not in df.columns: result.errors.append(f"Column '{col}' not found") result.is_valid = False continue
z_scores = (df[col] - df[col].mean()) / df[col].std() outliers = (z_scores.abs() > self.threshold).sum()
if outliers > 0: result.warnings.append(f"Column '{col}' has {outliers} outliers") result.info[f"{col}_outliers"] = int(outliers)
return resultCustom Model
from epftoolbox2.models.base import BaseModelfrom sklearn.ensemble import RandomForestRegressorfrom typing import Tupleimport numpy as np
class RandomForestModel(BaseModel): def __init__(self, predictors, training_window=365, n_estimators=100, **kwargs): super().__init__(predictors, training_window, **kwargs) self.n_estimators = n_estimators
def _fit_predict(self, train_x: np.ndarray, train_y: np.ndarray, test_x: np.ndarray) -> Tuple[float, list]: model = RandomForestRegressor(n_estimators=self.n_estimators, n_jobs=1) model.fit(train_x, train_y) prediction = model.predict(test_x)[0] importances = model.feature_importances_.tolist() return prediction, importancesCustom Evaluator
Create custom metrics by extending the Evaluator base class:
from epftoolbox2.evaluators.base import Evaluatorimport pandas as pdimport numpy as np
class MAPEEvaluator(Evaluator): name = "MAPE"
def compute(self, df: pd.DataFrame, **kwargs) -> float: return ((df["prediction"] - df["actual"]).abs() / df["actual"].abs()).mean() * 100
class sMAPEEvaluator(Evaluator): name = "sMAPE"
def compute(self, df: pd.DataFrame, **kwargs) -> float: numerator = (df["prediction"] - df["actual"]).abs() denominator = (df["prediction"].abs() + df["actual"].abs()) / 2 return (numerator / denominator).mean() * 100Evaluator Interface
Every evaluator must implement:
name: class attribute used as the column header in reportscompute(self, df, **kwargs) -> float: aggregate metric over a DataFrame withpredictionandactualcolumns
The **kwargs may include model_dfs: a dict of all model DataFrames for the current data slice, used by cross-model evaluators like rMAEEvaluator.
Using Custom Evaluators
from epftoolbox2.pipelines import ModelPipelinefrom epftoolbox2.models import OLSModelfrom epftoolbox2.evaluators import MAEEvaluator, RMSEEvaluatorfrom epftoolbox2.exporters import TerminalExporter
pipeline = ( ModelPipeline() .add_model(OLSModel(predictors=predictors, name="OLS")) .add_evaluator(MAEEvaluator()) .add_evaluator(RMSEEvaluator()) .add_evaluator(MAPEEvaluator()) # Custom .add_exporter(TerminalExporter()))
report = pipeline.run(...)
print(report.summary())# model MAE RMSE MAPE# 0 OLS 26.0199 32.4512 15.23Custom Exporter
from epftoolbox2.exporters.base import Exporterfrom epftoolbox2.results.report import EvaluationReportimport json
class JsonExporter(Exporter): def __init__(self, path: str): self.path = path
def export(self, report: EvaluationReport) -> None: data = { "summary": report.summary().to_dict(orient="records"), "by_horizon": report.by_horizon().to_dict(orient="records"), "by_hour": report.by_hour().to_dict(orient="records"), } with open(self.path, "w") as f: json.dump(data, f, indent=2)Using Custom Components
from epftoolbox2.pipelines import DataPipeline, ModelPipeline
# Data Pipeline with custom componentsdata_pipeline = ( DataPipeline() .add_source(MyApiSource(api_url="...", api_key="...")) .add_transformer(RollingMeanTransformer(columns=["price"])) .add_validator(OutlierValidator(columns=["price"])))
# Model Pipeline with custom componentsmodel_pipeline = ( ModelPipeline() .add_model(RandomForestModel(predictors=predictors, name="RF")) .add_evaluator(RMSEEvaluator()) .add_evaluator(MAPEEvaluator()) .add_exporter(JsonExporter("results.json")))