ml4t-diagnostic 0.1.0a1__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.
- ml4t/diagnostic/AGENT.md +25 -0
- ml4t/diagnostic/__init__.py +166 -0
- ml4t/diagnostic/backends/__init__.py +10 -0
- ml4t/diagnostic/backends/adapter.py +192 -0
- ml4t/diagnostic/backends/polars_backend.py +899 -0
- ml4t/diagnostic/caching/__init__.py +40 -0
- ml4t/diagnostic/caching/cache.py +331 -0
- ml4t/diagnostic/caching/decorators.py +131 -0
- ml4t/diagnostic/caching/smart_cache.py +339 -0
- ml4t/diagnostic/config/AGENT.md +24 -0
- ml4t/diagnostic/config/README.md +267 -0
- ml4t/diagnostic/config/__init__.py +219 -0
- ml4t/diagnostic/config/barrier_config.py +277 -0
- ml4t/diagnostic/config/base.py +301 -0
- ml4t/diagnostic/config/event_config.py +148 -0
- ml4t/diagnostic/config/feature_config.py +404 -0
- ml4t/diagnostic/config/multi_signal_config.py +55 -0
- ml4t/diagnostic/config/portfolio_config.py +215 -0
- ml4t/diagnostic/config/report_config.py +391 -0
- ml4t/diagnostic/config/sharpe_config.py +202 -0
- ml4t/diagnostic/config/signal_config.py +206 -0
- ml4t/diagnostic/config/trade_analysis_config.py +310 -0
- ml4t/diagnostic/config/validation.py +279 -0
- ml4t/diagnostic/core/__init__.py +29 -0
- ml4t/diagnostic/core/numba_utils.py +315 -0
- ml4t/diagnostic/core/purging.py +372 -0
- ml4t/diagnostic/core/sampling.py +471 -0
- ml4t/diagnostic/errors/__init__.py +205 -0
- ml4t/diagnostic/evaluation/AGENT.md +26 -0
- ml4t/diagnostic/evaluation/__init__.py +437 -0
- ml4t/diagnostic/evaluation/autocorrelation.py +531 -0
- ml4t/diagnostic/evaluation/barrier_analysis.py +1050 -0
- ml4t/diagnostic/evaluation/binary_metrics.py +910 -0
- ml4t/diagnostic/evaluation/dashboard.py +715 -0
- ml4t/diagnostic/evaluation/diagnostic_plots.py +1037 -0
- ml4t/diagnostic/evaluation/distribution/__init__.py +499 -0
- ml4t/diagnostic/evaluation/distribution/moments.py +299 -0
- ml4t/diagnostic/evaluation/distribution/tails.py +777 -0
- ml4t/diagnostic/evaluation/distribution/tests.py +470 -0
- ml4t/diagnostic/evaluation/drift/__init__.py +139 -0
- ml4t/diagnostic/evaluation/drift/analysis.py +432 -0
- ml4t/diagnostic/evaluation/drift/domain_classifier.py +517 -0
- ml4t/diagnostic/evaluation/drift/population_stability_index.py +310 -0
- ml4t/diagnostic/evaluation/drift/wasserstein.py +388 -0
- ml4t/diagnostic/evaluation/event_analysis.py +647 -0
- ml4t/diagnostic/evaluation/excursion.py +390 -0
- ml4t/diagnostic/evaluation/feature_diagnostics.py +873 -0
- ml4t/diagnostic/evaluation/feature_outcome.py +666 -0
- ml4t/diagnostic/evaluation/framework.py +935 -0
- ml4t/diagnostic/evaluation/metric_registry.py +255 -0
- ml4t/diagnostic/evaluation/metrics/AGENT.md +23 -0
- ml4t/diagnostic/evaluation/metrics/__init__.py +133 -0
- ml4t/diagnostic/evaluation/metrics/basic.py +160 -0
- ml4t/diagnostic/evaluation/metrics/conditional_ic.py +469 -0
- ml4t/diagnostic/evaluation/metrics/feature_outcome.py +475 -0
- ml4t/diagnostic/evaluation/metrics/ic_statistics.py +446 -0
- ml4t/diagnostic/evaluation/metrics/importance_analysis.py +338 -0
- ml4t/diagnostic/evaluation/metrics/importance_classical.py +375 -0
- ml4t/diagnostic/evaluation/metrics/importance_mda.py +371 -0
- ml4t/diagnostic/evaluation/metrics/importance_shap.py +715 -0
- ml4t/diagnostic/evaluation/metrics/information_coefficient.py +527 -0
- ml4t/diagnostic/evaluation/metrics/interactions.py +772 -0
- ml4t/diagnostic/evaluation/metrics/monotonicity.py +226 -0
- ml4t/diagnostic/evaluation/metrics/risk_adjusted.py +324 -0
- ml4t/diagnostic/evaluation/multi_signal.py +550 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/__init__.py +83 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/analysis.py +734 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/metrics.py +589 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/results.py +334 -0
- ml4t/diagnostic/evaluation/report_generation.py +824 -0
- ml4t/diagnostic/evaluation/signal_selector.py +452 -0
- ml4t/diagnostic/evaluation/stat_registry.py +139 -0
- ml4t/diagnostic/evaluation/stationarity/__init__.py +97 -0
- ml4t/diagnostic/evaluation/stationarity/analysis.py +518 -0
- ml4t/diagnostic/evaluation/stationarity/augmented_dickey_fuller.py +296 -0
- ml4t/diagnostic/evaluation/stationarity/kpss_test.py +308 -0
- ml4t/diagnostic/evaluation/stationarity/phillips_perron.py +365 -0
- ml4t/diagnostic/evaluation/stats/AGENT.md +43 -0
- ml4t/diagnostic/evaluation/stats/__init__.py +191 -0
- ml4t/diagnostic/evaluation/stats/backtest_overfitting.py +219 -0
- ml4t/diagnostic/evaluation/stats/bootstrap.py +228 -0
- ml4t/diagnostic/evaluation/stats/deflated_sharpe_ratio.py +591 -0
- ml4t/diagnostic/evaluation/stats/false_discovery_rate.py +295 -0
- ml4t/diagnostic/evaluation/stats/hac_standard_errors.py +108 -0
- ml4t/diagnostic/evaluation/stats/minimum_track_record.py +408 -0
- ml4t/diagnostic/evaluation/stats/moments.py +164 -0
- ml4t/diagnostic/evaluation/stats/rademacher_adjustment.py +436 -0
- ml4t/diagnostic/evaluation/stats/reality_check.py +155 -0
- ml4t/diagnostic/evaluation/stats/sharpe_inference.py +219 -0
- ml4t/diagnostic/evaluation/themes.py +330 -0
- ml4t/diagnostic/evaluation/threshold_analysis.py +957 -0
- ml4t/diagnostic/evaluation/trade_analysis.py +1136 -0
- ml4t/diagnostic/evaluation/trade_dashboard/__init__.py +32 -0
- ml4t/diagnostic/evaluation/trade_dashboard/app.py +315 -0
- ml4t/diagnostic/evaluation/trade_dashboard/export/__init__.py +18 -0
- ml4t/diagnostic/evaluation/trade_dashboard/export/csv.py +82 -0
- ml4t/diagnostic/evaluation/trade_dashboard/export/html.py +276 -0
- ml4t/diagnostic/evaluation/trade_dashboard/io.py +166 -0
- ml4t/diagnostic/evaluation/trade_dashboard/normalize.py +304 -0
- ml4t/diagnostic/evaluation/trade_dashboard/stats.py +386 -0
- ml4t/diagnostic/evaluation/trade_dashboard/style.py +79 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/__init__.py +21 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/patterns.py +354 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/shap_analysis.py +280 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/stat_validation.py +186 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/worst_trades.py +236 -0
- ml4t/diagnostic/evaluation/trade_dashboard/types.py +129 -0
- ml4t/diagnostic/evaluation/trade_shap/__init__.py +102 -0
- ml4t/diagnostic/evaluation/trade_shap/alignment.py +188 -0
- ml4t/diagnostic/evaluation/trade_shap/characterize.py +413 -0
- ml4t/diagnostic/evaluation/trade_shap/cluster.py +302 -0
- ml4t/diagnostic/evaluation/trade_shap/explain.py +208 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/__init__.py +23 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/generator.py +290 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/matcher.py +251 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/templates.yaml +467 -0
- ml4t/diagnostic/evaluation/trade_shap/models.py +386 -0
- ml4t/diagnostic/evaluation/trade_shap/normalize.py +116 -0
- ml4t/diagnostic/evaluation/trade_shap/pipeline.py +263 -0
- ml4t/diagnostic/evaluation/trade_shap_dashboard.py +283 -0
- ml4t/diagnostic/evaluation/trade_shap_diagnostics.py +588 -0
- ml4t/diagnostic/evaluation/validated_cv.py +535 -0
- ml4t/diagnostic/evaluation/visualization.py +1050 -0
- ml4t/diagnostic/evaluation/volatility/__init__.py +45 -0
- ml4t/diagnostic/evaluation/volatility/analysis.py +351 -0
- ml4t/diagnostic/evaluation/volatility/arch.py +258 -0
- ml4t/diagnostic/evaluation/volatility/garch.py +460 -0
- ml4t/diagnostic/integration/__init__.py +48 -0
- ml4t/diagnostic/integration/backtest_contract.py +671 -0
- ml4t/diagnostic/integration/data_contract.py +316 -0
- ml4t/diagnostic/integration/engineer_contract.py +226 -0
- ml4t/diagnostic/logging/__init__.py +77 -0
- ml4t/diagnostic/logging/logger.py +245 -0
- ml4t/diagnostic/logging/performance.py +234 -0
- ml4t/diagnostic/logging/progress.py +234 -0
- ml4t/diagnostic/logging/wandb.py +412 -0
- ml4t/diagnostic/metrics/__init__.py +9 -0
- ml4t/diagnostic/metrics/percentiles.py +128 -0
- ml4t/diagnostic/py.typed +1 -0
- ml4t/diagnostic/reporting/__init__.py +43 -0
- ml4t/diagnostic/reporting/base.py +130 -0
- ml4t/diagnostic/reporting/html_renderer.py +275 -0
- ml4t/diagnostic/reporting/json_renderer.py +51 -0
- ml4t/diagnostic/reporting/markdown_renderer.py +117 -0
- ml4t/diagnostic/results/AGENT.md +24 -0
- ml4t/diagnostic/results/__init__.py +105 -0
- ml4t/diagnostic/results/barrier_results/__init__.py +36 -0
- ml4t/diagnostic/results/barrier_results/hit_rate.py +304 -0
- ml4t/diagnostic/results/barrier_results/precision_recall.py +266 -0
- ml4t/diagnostic/results/barrier_results/profit_factor.py +297 -0
- ml4t/diagnostic/results/barrier_results/tearsheet.py +397 -0
- ml4t/diagnostic/results/barrier_results/time_to_target.py +305 -0
- ml4t/diagnostic/results/barrier_results/validation.py +38 -0
- ml4t/diagnostic/results/base.py +177 -0
- ml4t/diagnostic/results/event_results.py +349 -0
- ml4t/diagnostic/results/feature_results.py +787 -0
- ml4t/diagnostic/results/multi_signal_results.py +431 -0
- ml4t/diagnostic/results/portfolio_results.py +281 -0
- ml4t/diagnostic/results/sharpe_results.py +448 -0
- ml4t/diagnostic/results/signal_results/__init__.py +74 -0
- ml4t/diagnostic/results/signal_results/ic.py +581 -0
- ml4t/diagnostic/results/signal_results/irtc.py +110 -0
- ml4t/diagnostic/results/signal_results/quantile.py +392 -0
- ml4t/diagnostic/results/signal_results/tearsheet.py +456 -0
- ml4t/diagnostic/results/signal_results/turnover.py +213 -0
- ml4t/diagnostic/results/signal_results/validation.py +147 -0
- ml4t/diagnostic/signal/AGENT.md +17 -0
- ml4t/diagnostic/signal/__init__.py +69 -0
- ml4t/diagnostic/signal/_report.py +152 -0
- ml4t/diagnostic/signal/_utils.py +261 -0
- ml4t/diagnostic/signal/core.py +275 -0
- ml4t/diagnostic/signal/quantile.py +148 -0
- ml4t/diagnostic/signal/result.py +214 -0
- ml4t/diagnostic/signal/signal_ic.py +129 -0
- ml4t/diagnostic/signal/turnover.py +182 -0
- ml4t/diagnostic/splitters/AGENT.md +19 -0
- ml4t/diagnostic/splitters/__init__.py +36 -0
- ml4t/diagnostic/splitters/base.py +501 -0
- ml4t/diagnostic/splitters/calendar.py +421 -0
- ml4t/diagnostic/splitters/calendar_config.py +91 -0
- ml4t/diagnostic/splitters/combinatorial.py +1064 -0
- ml4t/diagnostic/splitters/config.py +322 -0
- ml4t/diagnostic/splitters/cpcv/__init__.py +57 -0
- ml4t/diagnostic/splitters/cpcv/combinations.py +119 -0
- ml4t/diagnostic/splitters/cpcv/partitioning.py +263 -0
- ml4t/diagnostic/splitters/cpcv/purge_engine.py +379 -0
- ml4t/diagnostic/splitters/cpcv/windows.py +190 -0
- ml4t/diagnostic/splitters/group_isolation.py +329 -0
- ml4t/diagnostic/splitters/persistence.py +316 -0
- ml4t/diagnostic/splitters/utils.py +207 -0
- ml4t/diagnostic/splitters/walk_forward.py +757 -0
- ml4t/diagnostic/utils/__init__.py +42 -0
- ml4t/diagnostic/utils/config.py +542 -0
- ml4t/diagnostic/utils/dependencies.py +318 -0
- ml4t/diagnostic/utils/sessions.py +127 -0
- ml4t/diagnostic/validation/__init__.py +54 -0
- ml4t/diagnostic/validation/dataframe.py +274 -0
- ml4t/diagnostic/validation/returns.py +280 -0
- ml4t/diagnostic/validation/timeseries.py +299 -0
- ml4t/diagnostic/visualization/AGENT.md +19 -0
- ml4t/diagnostic/visualization/__init__.py +223 -0
- ml4t/diagnostic/visualization/backtest/__init__.py +98 -0
- ml4t/diagnostic/visualization/backtest/cost_attribution.py +762 -0
- ml4t/diagnostic/visualization/backtest/executive_summary.py +895 -0
- ml4t/diagnostic/visualization/backtest/interactive_controls.py +673 -0
- ml4t/diagnostic/visualization/backtest/statistical_validity.py +874 -0
- ml4t/diagnostic/visualization/backtest/tearsheet.py +565 -0
- ml4t/diagnostic/visualization/backtest/template_system.py +373 -0
- ml4t/diagnostic/visualization/backtest/trade_plots.py +1172 -0
- ml4t/diagnostic/visualization/barrier_plots.py +782 -0
- ml4t/diagnostic/visualization/core.py +1060 -0
- ml4t/diagnostic/visualization/dashboards/__init__.py +36 -0
- ml4t/diagnostic/visualization/dashboards/base.py +582 -0
- ml4t/diagnostic/visualization/dashboards/importance.py +801 -0
- ml4t/diagnostic/visualization/dashboards/interaction.py +263 -0
- ml4t/diagnostic/visualization/dashboards.py +43 -0
- ml4t/diagnostic/visualization/data_extraction/__init__.py +48 -0
- ml4t/diagnostic/visualization/data_extraction/importance.py +649 -0
- ml4t/diagnostic/visualization/data_extraction/interaction.py +504 -0
- ml4t/diagnostic/visualization/data_extraction/types.py +113 -0
- ml4t/diagnostic/visualization/data_extraction/validation.py +66 -0
- ml4t/diagnostic/visualization/feature_plots.py +888 -0
- ml4t/diagnostic/visualization/interaction_plots.py +618 -0
- ml4t/diagnostic/visualization/portfolio/__init__.py +41 -0
- ml4t/diagnostic/visualization/portfolio/dashboard.py +514 -0
- ml4t/diagnostic/visualization/portfolio/drawdown_plots.py +341 -0
- ml4t/diagnostic/visualization/portfolio/returns_plots.py +487 -0
- ml4t/diagnostic/visualization/portfolio/risk_plots.py +301 -0
- ml4t/diagnostic/visualization/report_generation.py +1343 -0
- ml4t/diagnostic/visualization/signal/__init__.py +103 -0
- ml4t/diagnostic/visualization/signal/dashboard.py +911 -0
- ml4t/diagnostic/visualization/signal/event_plots.py +514 -0
- ml4t/diagnostic/visualization/signal/ic_plots.py +635 -0
- ml4t/diagnostic/visualization/signal/multi_signal_dashboard.py +974 -0
- ml4t/diagnostic/visualization/signal/multi_signal_plots.py +603 -0
- ml4t/diagnostic/visualization/signal/quantile_plots.py +625 -0
- ml4t/diagnostic/visualization/signal/turnover_plots.py +400 -0
- ml4t/diagnostic/visualization/trade_shap/__init__.py +90 -0
- ml4t_diagnostic-0.1.0a1.dist-info/METADATA +1044 -0
- ml4t_diagnostic-0.1.0a1.dist-info/RECORD +242 -0
- ml4t_diagnostic-0.1.0a1.dist-info/WHEEL +4 -0
- ml4t_diagnostic-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""Result schemas for feature evaluation modules (A, B, C).
|
|
2
|
+
|
|
3
|
+
Module A: Feature Diagnostics (stationarity, ACF, volatility clustering)
|
|
4
|
+
Module B: Cross-Feature Analysis (correlations, PCA, clustering)
|
|
5
|
+
Module C: Feature-Outcome Relationships (IC analysis, threshold analysis)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from ml4t.diagnostic.results.base import BaseResult
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ml4t.diagnostic.integration.engineer_contract import EngineerConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# =============================================================================
|
|
22
|
+
# Module A: Feature Diagnostics
|
|
23
|
+
# =============================================================================
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StationarityTestResult(BaseResult):
|
|
27
|
+
"""Results from stationarity tests (ADF, KPSS, PP).
|
|
28
|
+
|
|
29
|
+
Tests whether a time series is stationary (mean-reverting) or has unit root.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
feature_name: Name of feature tested
|
|
33
|
+
adf_statistic: Augmented Dickey-Fuller test statistic
|
|
34
|
+
adf_pvalue: ADF p-value (reject H0 if < alpha => stationary)
|
|
35
|
+
adf_is_stationary: Whether ADF indicates stationarity
|
|
36
|
+
adf_critical_values: ADF critical values at 1%, 5%, 10% levels
|
|
37
|
+
adf_lags_used: Number of lags used in ADF test
|
|
38
|
+
adf_n_obs: Number of observations used in ADF test
|
|
39
|
+
kpss_statistic: KPSS test statistic
|
|
40
|
+
kpss_pvalue: KPSS p-value (reject H0 if < alpha => non-stationary)
|
|
41
|
+
kpss_is_stationary: Whether KPSS indicates stationarity
|
|
42
|
+
pp_statistic: Phillips-Perron test statistic
|
|
43
|
+
pp_pvalue: PP p-value
|
|
44
|
+
pp_is_stationary: Whether PP indicates stationarity
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
analysis_type: str = "stationarity_test"
|
|
48
|
+
feature_name: str = Field(..., description="Feature name")
|
|
49
|
+
|
|
50
|
+
# ADF test
|
|
51
|
+
adf_statistic: float | None = Field(None, description="ADF test statistic")
|
|
52
|
+
adf_pvalue: float | None = Field(None, description="ADF p-value")
|
|
53
|
+
adf_is_stationary: bool | None = Field(None, description="ADF stationarity")
|
|
54
|
+
adf_critical_values: dict[str, float] | None = Field(
|
|
55
|
+
None, description="ADF critical values (1%, 5%, 10%)"
|
|
56
|
+
)
|
|
57
|
+
adf_lags_used: int | None = Field(None, description="Lags used in ADF test")
|
|
58
|
+
adf_n_obs: int | None = Field(None, description="Observations in ADF test")
|
|
59
|
+
|
|
60
|
+
# KPSS test
|
|
61
|
+
kpss_statistic: float | None = Field(None, description="KPSS test statistic")
|
|
62
|
+
kpss_pvalue: float | None = Field(None, description="KPSS p-value")
|
|
63
|
+
kpss_is_stationary: bool | None = Field(None, description="KPSS stationarity")
|
|
64
|
+
|
|
65
|
+
# Phillips-Perron test
|
|
66
|
+
pp_statistic: float | None = Field(None, description="PP test statistic")
|
|
67
|
+
pp_pvalue: float | None = Field(None, description="PP p-value")
|
|
68
|
+
pp_is_stationary: bool | None = Field(None, description="PP stationarity")
|
|
69
|
+
|
|
70
|
+
def list_available_dataframes(self) -> list[str]:
|
|
71
|
+
"""List available DataFrame views.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List with single 'primary' view containing all test results
|
|
75
|
+
"""
|
|
76
|
+
return ["primary"]
|
|
77
|
+
|
|
78
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
79
|
+
"""Get test results as DataFrame.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name: DataFrame name (ignored, only 'primary' available)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
DataFrame with test statistics and conclusions
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If name is provided but not 'primary'
|
|
89
|
+
"""
|
|
90
|
+
if name is not None and name != "primary":
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Unknown DataFrame name: {name}. Available: {self.list_available_dataframes()}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
data = {
|
|
96
|
+
"feature": [self.feature_name],
|
|
97
|
+
"adf_statistic": [self.adf_statistic],
|
|
98
|
+
"adf_pvalue": [self.adf_pvalue],
|
|
99
|
+
"adf_stationary": [self.adf_is_stationary],
|
|
100
|
+
"adf_lags_used": [self.adf_lags_used],
|
|
101
|
+
"adf_n_obs": [self.adf_n_obs],
|
|
102
|
+
"kpss_statistic": [self.kpss_statistic],
|
|
103
|
+
"kpss_pvalue": [self.kpss_pvalue],
|
|
104
|
+
"kpss_stationary": [self.kpss_is_stationary],
|
|
105
|
+
"pp_statistic": [self.pp_statistic],
|
|
106
|
+
"pp_pvalue": [self.pp_pvalue],
|
|
107
|
+
"pp_stationary": [self.pp_is_stationary],
|
|
108
|
+
}
|
|
109
|
+
return pl.DataFrame(data)
|
|
110
|
+
|
|
111
|
+
def summary(self) -> str:
|
|
112
|
+
"""Human-readable summary of stationarity tests."""
|
|
113
|
+
lines = [f"Stationarity Tests: {self.feature_name}"]
|
|
114
|
+
if self.adf_is_stationary is not None:
|
|
115
|
+
lines.append(
|
|
116
|
+
f" ADF: {'Stationary' if self.adf_is_stationary else 'Non-stationary'} (p={self.adf_pvalue:.4f})"
|
|
117
|
+
)
|
|
118
|
+
if self.kpss_is_stationary is not None:
|
|
119
|
+
lines.append(
|
|
120
|
+
f" KPSS: {'Stationary' if self.kpss_is_stationary else 'Non-stationary'} (p={self.kpss_pvalue:.4f})"
|
|
121
|
+
)
|
|
122
|
+
if self.pp_is_stationary is not None:
|
|
123
|
+
lines.append(
|
|
124
|
+
f" PP: {'Stationary' if self.pp_is_stationary else 'Non-stationary'} (p={self.pp_pvalue:.4f})"
|
|
125
|
+
)
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ACFResult(BaseResult):
|
|
130
|
+
"""Autocorrelation Function (ACF) and Partial ACF analysis results.
|
|
131
|
+
|
|
132
|
+
Detects serial correlation and lag structure in time series.
|
|
133
|
+
|
|
134
|
+
Attributes:
|
|
135
|
+
feature_name: Name of feature analyzed
|
|
136
|
+
acf_values: ACF values at each lag
|
|
137
|
+
pacf_values: PACF values at each lag
|
|
138
|
+
significant_lags_acf: List of lags with significant ACF
|
|
139
|
+
significant_lags_pacf: List of lags with significant PACF
|
|
140
|
+
ljung_box_statistic: Ljung-Box test statistic
|
|
141
|
+
ljung_box_pvalue: Ljung-Box p-value (reject H0 => autocorrelation present)
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
analysis_type: str = "acf_analysis"
|
|
145
|
+
feature_name: str = Field(..., description="Feature name")
|
|
146
|
+
|
|
147
|
+
acf_values: list[float] = Field(..., description="ACF at each lag")
|
|
148
|
+
pacf_values: list[float] = Field(..., description="PACF at each lag")
|
|
149
|
+
significant_lags_acf: list[int] = Field(
|
|
150
|
+
default_factory=list, description="Lags with significant ACF"
|
|
151
|
+
)
|
|
152
|
+
significant_lags_pacf: list[int] = Field(
|
|
153
|
+
default_factory=list, description="Lags with significant PACF"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
ljung_box_statistic: float | None = Field(None, description="Ljung-Box statistic")
|
|
157
|
+
ljung_box_pvalue: float | None = Field(None, description="Ljung-Box p-value")
|
|
158
|
+
|
|
159
|
+
def list_available_dataframes(self) -> list[str]:
|
|
160
|
+
"""List available DataFrame views.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List with single 'primary' view containing ACF/PACF values
|
|
164
|
+
"""
|
|
165
|
+
return ["primary"]
|
|
166
|
+
|
|
167
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
168
|
+
"""Get ACF/PACF values as DataFrame.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
name: DataFrame name (ignored, only 'primary' available)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
DataFrame with lag, ACF, and PACF values
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If name is provided but not 'primary'
|
|
178
|
+
"""
|
|
179
|
+
if name is not None and name != "primary":
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"Unknown DataFrame name: {name}. Available: {self.list_available_dataframes()}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
n_lags = len(self.acf_values)
|
|
185
|
+
data = {
|
|
186
|
+
"lag": list(range(n_lags)),
|
|
187
|
+
"acf": self.acf_values,
|
|
188
|
+
"pacf": self.pacf_values,
|
|
189
|
+
}
|
|
190
|
+
return pl.DataFrame(data)
|
|
191
|
+
|
|
192
|
+
def summary(self) -> str:
|
|
193
|
+
"""Human-readable summary of autocorrelation analysis."""
|
|
194
|
+
lines = [f"ACF/PACF Analysis: {self.feature_name}"]
|
|
195
|
+
lines.append(f" Lags analyzed: {len(self.acf_values)}")
|
|
196
|
+
lines.append(f" Significant ACF lags: {self.significant_lags_acf}")
|
|
197
|
+
lines.append(f" Significant PACF lags: {self.significant_lags_pacf}")
|
|
198
|
+
if self.ljung_box_pvalue is not None:
|
|
199
|
+
lines.append(
|
|
200
|
+
f" Ljung-Box test: p={self.ljung_box_pvalue:.4f} "
|
|
201
|
+
f"({'Autocorrelation present' if self.ljung_box_pvalue < 0.05 else 'No autocorrelation'})"
|
|
202
|
+
)
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class FeatureDiagnosticsResult(BaseResult):
|
|
207
|
+
"""Complete results from Module A: Feature Diagnostics.
|
|
208
|
+
|
|
209
|
+
Comprehensive analysis of individual feature properties:
|
|
210
|
+
- Stationarity testing (ADF, KPSS, PP)
|
|
211
|
+
- Autocorrelation structure (ACF, PACF)
|
|
212
|
+
- Volatility clustering (GARCH effects)
|
|
213
|
+
- Distribution characteristics (normality, skewness, kurtosis)
|
|
214
|
+
|
|
215
|
+
Attributes:
|
|
216
|
+
stationarity_tests: Stationarity test results for each feature
|
|
217
|
+
acf_results: ACF/PACF analysis for each feature
|
|
218
|
+
volatility_clustering: GARCH detection results
|
|
219
|
+
distribution_stats: Distribution characteristics
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
analysis_type: str = "feature_diagnostics"
|
|
223
|
+
|
|
224
|
+
stationarity_tests: list[StationarityTestResult] = Field(
|
|
225
|
+
default_factory=list, description="Stationarity test results"
|
|
226
|
+
)
|
|
227
|
+
acf_results: list[ACFResult] = Field(
|
|
228
|
+
default_factory=list, description="ACF/PACF analysis results"
|
|
229
|
+
)
|
|
230
|
+
volatility_clustering: dict[str, Any] = Field(
|
|
231
|
+
default_factory=dict, description="GARCH detection results"
|
|
232
|
+
)
|
|
233
|
+
distribution_stats: dict[str, Any] = Field(
|
|
234
|
+
default_factory=dict, description="Distribution characteristics"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def get_stationarity_dataframe(self) -> pl.DataFrame:
|
|
238
|
+
"""Get stationarity test results as DataFrame.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
DataFrame with all stationarity tests
|
|
242
|
+
"""
|
|
243
|
+
if not self.stationarity_tests:
|
|
244
|
+
return pl.DataFrame()
|
|
245
|
+
|
|
246
|
+
# Combine all test results
|
|
247
|
+
dfs = [test.get_dataframe() for test in self.stationarity_tests]
|
|
248
|
+
return pl.concat(dfs)
|
|
249
|
+
|
|
250
|
+
def get_acf_dataframe(self, feature_name: str | None = None) -> pl.DataFrame:
|
|
251
|
+
"""Get ACF/PACF results as DataFrame.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
feature_name: Optional filter by feature
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
DataFrame with ACF/PACF values
|
|
258
|
+
"""
|
|
259
|
+
if not self.acf_results:
|
|
260
|
+
return pl.DataFrame()
|
|
261
|
+
|
|
262
|
+
results = self.acf_results
|
|
263
|
+
if feature_name:
|
|
264
|
+
results = [r for r in results if r.feature_name == feature_name]
|
|
265
|
+
|
|
266
|
+
dfs = []
|
|
267
|
+
for result in results:
|
|
268
|
+
df = result.get_dataframe()
|
|
269
|
+
df = df.with_columns(pl.lit(result.feature_name).alias("feature"))
|
|
270
|
+
dfs.append(df)
|
|
271
|
+
|
|
272
|
+
return pl.concat(dfs) if dfs else pl.DataFrame()
|
|
273
|
+
|
|
274
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
275
|
+
"""Get results as DataFrame.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
name: 'stationarity' or 'acf'
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Requested DataFrame
|
|
282
|
+
"""
|
|
283
|
+
if name == "stationarity":
|
|
284
|
+
return self.get_stationarity_dataframe()
|
|
285
|
+
elif name == "acf":
|
|
286
|
+
return self.get_acf_dataframe()
|
|
287
|
+
else:
|
|
288
|
+
return self.get_stationarity_dataframe()
|
|
289
|
+
|
|
290
|
+
def summary(self) -> str:
|
|
291
|
+
"""Human-readable summary of diagnostics."""
|
|
292
|
+
lines = ["Feature Diagnostics Summary", "=" * 40]
|
|
293
|
+
lines.append(f"Features analyzed: {len(self.stationarity_tests)}")
|
|
294
|
+
lines.append("")
|
|
295
|
+
|
|
296
|
+
# Stationarity summary
|
|
297
|
+
if self.stationarity_tests:
|
|
298
|
+
stationary = sum(
|
|
299
|
+
1 for t in self.stationarity_tests if t.adf_is_stationary or t.kpss_is_stationary
|
|
300
|
+
)
|
|
301
|
+
lines.append(f"Stationary features: {stationary}/{len(self.stationarity_tests)}")
|
|
302
|
+
|
|
303
|
+
# ACF summary
|
|
304
|
+
if self.acf_results:
|
|
305
|
+
with_autocorr = sum(1 for r in self.acf_results if r.significant_lags_acf)
|
|
306
|
+
lines.append(f"Features with autocorrelation: {with_autocorr}/{len(self.acf_results)}")
|
|
307
|
+
|
|
308
|
+
return "\n".join(lines)
|
|
309
|
+
|
|
310
|
+
def to_engineer_config(self) -> EngineerConfig:
|
|
311
|
+
"""Generate preprocessing recommendations for ML4T Engineer.
|
|
312
|
+
|
|
313
|
+
Analyzes diagnostic results to recommend appropriate transforms:
|
|
314
|
+
- Non-stationary → DIFF (first difference)
|
|
315
|
+
- High skewness (>2) → LOG or SQRT transform
|
|
316
|
+
- Outliers detected → WINSORIZE
|
|
317
|
+
- Already good quality → NONE
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
EngineerConfig with preprocessing recommendations
|
|
321
|
+
|
|
322
|
+
Example:
|
|
323
|
+
>>> diagnostics = evaluator.evaluate_diagnostics(features_df)
|
|
324
|
+
>>> eng_config = diagnostics.to_engineer_config()
|
|
325
|
+
>>> preprocessing_dict = eng_config.to_dict()
|
|
326
|
+
"""
|
|
327
|
+
from ml4t.diagnostic.integration.engineer_contract import (
|
|
328
|
+
EngineerConfig,
|
|
329
|
+
PreprocessingRecommendation,
|
|
330
|
+
TransformType,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
recommendations = []
|
|
334
|
+
|
|
335
|
+
# Process stationarity tests
|
|
336
|
+
for stationarity in self.stationarity_tests:
|
|
337
|
+
feature_name = stationarity.feature_name
|
|
338
|
+
|
|
339
|
+
# Check if non-stationary (both ADF and KPSS should agree ideally)
|
|
340
|
+
adf_non_stationary = (
|
|
341
|
+
stationarity.adf_is_stationary is not None and not stationarity.adf_is_stationary
|
|
342
|
+
)
|
|
343
|
+
kpss_non_stationary = (
|
|
344
|
+
stationarity.kpss_is_stationary is not None and not stationarity.kpss_is_stationary
|
|
345
|
+
)
|
|
346
|
+
pp_non_stationary = (
|
|
347
|
+
stationarity.pp_is_stationary is not None and not stationarity.pp_is_stationary
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Count non-stationary signals
|
|
351
|
+
non_stationary_count = sum([adf_non_stationary, kpss_non_stationary, pp_non_stationary])
|
|
352
|
+
|
|
353
|
+
if non_stationary_count >= 2:
|
|
354
|
+
# At least 2 tests indicate non-stationarity
|
|
355
|
+
confidence = 0.9 if non_stationary_count == 3 else 0.8
|
|
356
|
+
diagnostics_dict = {}
|
|
357
|
+
if stationarity.adf_pvalue is not None:
|
|
358
|
+
diagnostics_dict["adf_pvalue"] = stationarity.adf_pvalue
|
|
359
|
+
if stationarity.kpss_pvalue is not None:
|
|
360
|
+
diagnostics_dict["kpss_pvalue"] = stationarity.kpss_pvalue
|
|
361
|
+
|
|
362
|
+
recommendations.append(
|
|
363
|
+
PreprocessingRecommendation(
|
|
364
|
+
feature_name=feature_name,
|
|
365
|
+
transform=TransformType.DIFF,
|
|
366
|
+
reason=f"Feature is non-stationary ({non_stationary_count}/3 tests)",
|
|
367
|
+
confidence=confidence,
|
|
368
|
+
diagnostics=diagnostics_dict if diagnostics_dict else None,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
elif non_stationary_count == 1:
|
|
372
|
+
# Only 1 test indicates non-stationarity - lower confidence
|
|
373
|
+
test_name = "ADF" if adf_non_stationary else "KPSS" if kpss_non_stationary else "PP"
|
|
374
|
+
pvalue: float | None = getattr(stationarity, f"{test_name.lower()}_pvalue")
|
|
375
|
+
single_test_diagnostics: dict[str, float] | None = (
|
|
376
|
+
{f"{test_name.lower()}_pvalue": pvalue} if pvalue is not None else None
|
|
377
|
+
)
|
|
378
|
+
recommendations.append(
|
|
379
|
+
PreprocessingRecommendation(
|
|
380
|
+
feature_name=feature_name,
|
|
381
|
+
transform=TransformType.DIFF,
|
|
382
|
+
reason=f"Possible non-stationarity ({test_name} test)",
|
|
383
|
+
confidence=0.6,
|
|
384
|
+
diagnostics=single_test_diagnostics,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
# Stationary - no transform needed
|
|
389
|
+
recommendations.append(
|
|
390
|
+
PreprocessingRecommendation(
|
|
391
|
+
feature_name=feature_name,
|
|
392
|
+
transform=TransformType.NONE,
|
|
393
|
+
reason="Feature is stationary (all tests)",
|
|
394
|
+
confidence=0.9,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Check distribution stats for skewness/outliers
|
|
399
|
+
# (This is a placeholder - actual implementation depends on what's in distribution_stats)
|
|
400
|
+
if self.distribution_stats:
|
|
401
|
+
for feature_name, stats in self.distribution_stats.items():
|
|
402
|
+
# Skip if already recommended differencing
|
|
403
|
+
if any(
|
|
404
|
+
r.feature_name == feature_name and r.transform == TransformType.DIFF
|
|
405
|
+
for r in recommendations
|
|
406
|
+
):
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Check for high skewness
|
|
410
|
+
skewness = stats.get("skewness")
|
|
411
|
+
if skewness is not None and abs(skewness) > 2:
|
|
412
|
+
# High positive skew → log transform
|
|
413
|
+
if skewness > 2:
|
|
414
|
+
recommendations.append(
|
|
415
|
+
PreprocessingRecommendation(
|
|
416
|
+
feature_name=feature_name,
|
|
417
|
+
transform=TransformType.LOG,
|
|
418
|
+
reason=f"High right skew (skewness={skewness:.2f})",
|
|
419
|
+
confidence=0.85,
|
|
420
|
+
diagnostics={"skewness": skewness},
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
# High negative skew → reflect and log (but we'll use sqrt as milder)
|
|
424
|
+
else:
|
|
425
|
+
recommendations.append(
|
|
426
|
+
PreprocessingRecommendation(
|
|
427
|
+
feature_name=feature_name,
|
|
428
|
+
transform=TransformType.SQRT,
|
|
429
|
+
reason=f"High left skew (skewness={skewness:.2f})",
|
|
430
|
+
confidence=0.75,
|
|
431
|
+
diagnostics={"skewness": skewness},
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Check for outliers
|
|
436
|
+
has_outliers = stats.get("has_outliers", False)
|
|
437
|
+
if has_outliers:
|
|
438
|
+
recommendations.append(
|
|
439
|
+
PreprocessingRecommendation(
|
|
440
|
+
feature_name=feature_name,
|
|
441
|
+
transform=TransformType.WINSORIZE,
|
|
442
|
+
reason="Outliers detected at tail percentiles",
|
|
443
|
+
confidence=0.8,
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return EngineerConfig(
|
|
448
|
+
recommendations=recommendations,
|
|
449
|
+
metadata={
|
|
450
|
+
"created_at": self.created_at,
|
|
451
|
+
"diagnostic_version": self.version,
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# =============================================================================
|
|
457
|
+
# Module B: Cross-Feature Analysis
|
|
458
|
+
# =============================================================================
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class CrossFeatureResult(BaseResult):
|
|
462
|
+
"""Results from Module B: Cross-Feature Analysis.
|
|
463
|
+
|
|
464
|
+
Analysis of relationships between features:
|
|
465
|
+
- Correlation matrix
|
|
466
|
+
- PCA (dimensionality reduction)
|
|
467
|
+
- Clustering (feature groups)
|
|
468
|
+
- Redundancy detection
|
|
469
|
+
|
|
470
|
+
Attributes:
|
|
471
|
+
correlation_matrix: Correlation matrix (stored as nested list for JSON)
|
|
472
|
+
feature_names: List of feature names
|
|
473
|
+
pca_results: PCA analysis results (variance explained, loadings)
|
|
474
|
+
clustering_results: Feature clustering results
|
|
475
|
+
redundant_features: Highly correlated feature pairs
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
analysis_type: str = "cross_feature"
|
|
479
|
+
|
|
480
|
+
correlation_matrix: list[list[float]] = Field(
|
|
481
|
+
..., description="Correlation matrix as nested list"
|
|
482
|
+
)
|
|
483
|
+
feature_names: list[str] = Field(..., description="Feature names in matrix order")
|
|
484
|
+
|
|
485
|
+
pca_results: dict[str, Any] | None = Field(
|
|
486
|
+
None, description="PCA analysis (variance explained, loadings)"
|
|
487
|
+
)
|
|
488
|
+
clustering_results: dict[str, Any] | None = Field(
|
|
489
|
+
None, description="Feature clustering results"
|
|
490
|
+
)
|
|
491
|
+
redundant_features: list[tuple[str, str, float]] | None = Field(
|
|
492
|
+
None, description="Redundant pairs: (feature1, feature2, correlation)"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def get_correlation_dataframe(self) -> pl.DataFrame:
|
|
496
|
+
"""Get correlation matrix as DataFrame.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
DataFrame with correlations in long format
|
|
500
|
+
"""
|
|
501
|
+
# Convert to long format for easier manipulation
|
|
502
|
+
n = len(self.feature_names)
|
|
503
|
+
rows = []
|
|
504
|
+
for i in range(n):
|
|
505
|
+
for j in range(n):
|
|
506
|
+
rows.append(
|
|
507
|
+
{
|
|
508
|
+
"feature_1": self.feature_names[i],
|
|
509
|
+
"feature_2": self.feature_names[j],
|
|
510
|
+
"correlation": self.correlation_matrix[i][j],
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
return pl.DataFrame(rows)
|
|
514
|
+
|
|
515
|
+
def get_redundancy_dataframe(self) -> pl.DataFrame:
|
|
516
|
+
"""Get redundant feature pairs as DataFrame.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
DataFrame with redundant pairs
|
|
520
|
+
"""
|
|
521
|
+
if not self.redundant_features:
|
|
522
|
+
return pl.DataFrame(
|
|
523
|
+
schema={"feature_1": pl.Utf8, "feature_2": pl.Utf8, "correlation": pl.Float64}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
rows = [
|
|
527
|
+
{"feature_1": f1, "feature_2": f2, "correlation": corr}
|
|
528
|
+
for f1, f2, corr in self.redundant_features
|
|
529
|
+
]
|
|
530
|
+
return pl.DataFrame(rows)
|
|
531
|
+
|
|
532
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
533
|
+
"""Get results as DataFrame.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
name: 'correlation' or 'redundancy'
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
Requested DataFrame
|
|
540
|
+
"""
|
|
541
|
+
if name == "redundancy":
|
|
542
|
+
return self.get_redundancy_dataframe()
|
|
543
|
+
else:
|
|
544
|
+
return self.get_correlation_dataframe()
|
|
545
|
+
|
|
546
|
+
def summary(self) -> str:
|
|
547
|
+
"""Human-readable summary of cross-feature analysis."""
|
|
548
|
+
lines = ["Cross-Feature Analysis Summary", "=" * 40]
|
|
549
|
+
lines.append(f"Features analyzed: {len(self.feature_names)}")
|
|
550
|
+
|
|
551
|
+
if self.redundant_features:
|
|
552
|
+
lines.append(f"Redundant pairs detected: {len(self.redundant_features)}")
|
|
553
|
+
for f1, f2, corr in self.redundant_features[:5]: # Show top 5
|
|
554
|
+
lines.append(f" {f1} <-> {f2}: {corr:.3f}")
|
|
555
|
+
if len(self.redundant_features) > 5:
|
|
556
|
+
lines.append(f" ... and {len(self.redundant_features) - 5} more")
|
|
557
|
+
|
|
558
|
+
if self.pca_results:
|
|
559
|
+
variance = self.pca_results.get("variance_explained", [])
|
|
560
|
+
if variance:
|
|
561
|
+
lines.append(
|
|
562
|
+
f"PCA: {len(variance)} components explain {sum(variance):.1%} variance"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return "\n".join(lines)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# =============================================================================
|
|
569
|
+
# Module C: Feature-Outcome Relationships
|
|
570
|
+
# =============================================================================
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class ICAnalysisResult(BaseResult):
|
|
574
|
+
"""Information Coefficient (IC) analysis for a single feature.
|
|
575
|
+
|
|
576
|
+
Measures correlation between feature ranks and outcome ranks,
|
|
577
|
+
with HAC adjustment for autocorrelation.
|
|
578
|
+
|
|
579
|
+
Attributes:
|
|
580
|
+
feature_name: Feature being analyzed
|
|
581
|
+
ic_values: IC at each lag (if lagged analysis)
|
|
582
|
+
mean_ic: Average IC across lags
|
|
583
|
+
ic_std: Standard deviation of IC
|
|
584
|
+
ic_ir: Information Ratio (mean_ic / ic_std)
|
|
585
|
+
pvalue: P-value for IC significance
|
|
586
|
+
hac_adjusted_pvalue: HAC-adjusted p-value
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
analysis_type: str = "ic_analysis"
|
|
590
|
+
feature_name: str = Field(..., description="Feature name")
|
|
591
|
+
|
|
592
|
+
ic_values: list[float] = Field(..., description="IC at each lag")
|
|
593
|
+
mean_ic: float = Field(..., description="Mean IC")
|
|
594
|
+
ic_std: float = Field(..., description="IC standard deviation")
|
|
595
|
+
ic_ir: float = Field(..., description="Information Ratio (mean / std)")
|
|
596
|
+
|
|
597
|
+
pvalue: float | None = Field(None, description="P-value for IC significance")
|
|
598
|
+
hac_adjusted_pvalue: float | None = Field(None, description="HAC-adjusted p-value")
|
|
599
|
+
|
|
600
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
601
|
+
"""Get IC values as DataFrame.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
name: Unused, included for base class compatibility.
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
DataFrame with lag and IC values
|
|
608
|
+
"""
|
|
609
|
+
del name # Unused, base class compatibility
|
|
610
|
+
data = {
|
|
611
|
+
"feature": [self.feature_name] * len(self.ic_values),
|
|
612
|
+
"lag": list(range(len(self.ic_values))),
|
|
613
|
+
"ic": self.ic_values,
|
|
614
|
+
}
|
|
615
|
+
return pl.DataFrame(data)
|
|
616
|
+
|
|
617
|
+
def summary(self) -> str:
|
|
618
|
+
"""Human-readable summary of IC analysis."""
|
|
619
|
+
lines = [f"IC Analysis: {self.feature_name}"]
|
|
620
|
+
lines.append(f" Mean IC: {self.mean_ic:.4f}")
|
|
621
|
+
lines.append(f" IC IR: {self.ic_ir:.4f}")
|
|
622
|
+
if self.hac_adjusted_pvalue is not None:
|
|
623
|
+
sig = "Significant" if self.hac_adjusted_pvalue < 0.05 else "Not significant"
|
|
624
|
+
lines.append(f" HAC p-value: {self.hac_adjusted_pvalue:.4f} ({sig})")
|
|
625
|
+
return "\n".join(lines)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class ThresholdAnalysisResult(BaseResult):
|
|
629
|
+
"""Binary classification threshold analysis for a single feature.
|
|
630
|
+
|
|
631
|
+
Evaluates feature as binary signal using optimal threshold.
|
|
632
|
+
|
|
633
|
+
Attributes:
|
|
634
|
+
feature_name: Feature being analyzed
|
|
635
|
+
optimal_threshold: Threshold value that optimizes target metric
|
|
636
|
+
precision: Precision at optimal threshold
|
|
637
|
+
recall: Recall at optimal threshold
|
|
638
|
+
f1_score: F1 score at optimal threshold
|
|
639
|
+
lift: Lift over base rate
|
|
640
|
+
coverage: Fraction of observations with positive signal
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
analysis_type: str = "threshold_analysis"
|
|
644
|
+
feature_name: str = Field(..., description="Feature name")
|
|
645
|
+
|
|
646
|
+
optimal_threshold: float = Field(..., description="Optimal threshold value")
|
|
647
|
+
precision: float = Field(..., description="Precision at optimal threshold")
|
|
648
|
+
recall: float = Field(..., description="Recall at optimal threshold")
|
|
649
|
+
f1_score: float = Field(..., description="F1 score at optimal threshold")
|
|
650
|
+
lift: float = Field(..., description="Lift over base rate")
|
|
651
|
+
coverage: float = Field(..., description="Signal coverage (fraction positive)")
|
|
652
|
+
|
|
653
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
654
|
+
"""Get threshold analysis as DataFrame.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
name: Unused, included for base class compatibility.
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Single-row DataFrame with all metrics
|
|
661
|
+
"""
|
|
662
|
+
del name # Unused, base class compatibility
|
|
663
|
+
data = {
|
|
664
|
+
"feature": [self.feature_name],
|
|
665
|
+
"threshold": [self.optimal_threshold],
|
|
666
|
+
"precision": [self.precision],
|
|
667
|
+
"recall": [self.recall],
|
|
668
|
+
"f1_score": [self.f1_score],
|
|
669
|
+
"lift": [self.lift],
|
|
670
|
+
"coverage": [self.coverage],
|
|
671
|
+
}
|
|
672
|
+
return pl.DataFrame(data)
|
|
673
|
+
|
|
674
|
+
def summary(self) -> str:
|
|
675
|
+
"""Human-readable summary of threshold analysis."""
|
|
676
|
+
lines = [f"Threshold Analysis: {self.feature_name}"]
|
|
677
|
+
lines.append(f" Optimal threshold: {self.optimal_threshold:.4f}")
|
|
678
|
+
lines.append(f" Precision: {self.precision:.2%}")
|
|
679
|
+
lines.append(f" Recall: {self.recall:.2%}")
|
|
680
|
+
lines.append(f" F1 Score: {self.f1_score:.2%}")
|
|
681
|
+
lines.append(f" Lift: {self.lift:.2f}x")
|
|
682
|
+
lines.append(f" Coverage: {self.coverage:.2%}")
|
|
683
|
+
return "\n".join(lines)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class FeatureOutcomeResult(BaseResult):
|
|
687
|
+
"""Complete results from Module C: Feature-Outcome Relationships.
|
|
688
|
+
|
|
689
|
+
Analysis of how features relate to outcomes:
|
|
690
|
+
- IC analysis (rank correlations)
|
|
691
|
+
- Threshold analysis (binary classification)
|
|
692
|
+
- ML feature importance (if applicable)
|
|
693
|
+
|
|
694
|
+
Attributes:
|
|
695
|
+
ic_results: IC analysis for each feature
|
|
696
|
+
threshold_results: Threshold analysis for each feature
|
|
697
|
+
ml_importance: ML feature importance scores
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
analysis_type: str = "feature_outcome"
|
|
701
|
+
|
|
702
|
+
ic_results: list[ICAnalysisResult] = Field(
|
|
703
|
+
default_factory=list, description="IC analysis per feature"
|
|
704
|
+
)
|
|
705
|
+
threshold_results: list[ThresholdAnalysisResult] | None = Field(
|
|
706
|
+
None, description="Threshold analysis per feature"
|
|
707
|
+
)
|
|
708
|
+
ml_importance: dict[str, float] | None = Field(
|
|
709
|
+
None, description="ML feature importance: {feature: importance}"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def get_ic_dataframe(self) -> pl.DataFrame:
|
|
713
|
+
"""Get IC analysis as DataFrame.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
DataFrame with IC metrics for all features
|
|
717
|
+
"""
|
|
718
|
+
if not self.ic_results:
|
|
719
|
+
return pl.DataFrame()
|
|
720
|
+
|
|
721
|
+
rows = []
|
|
722
|
+
for result in self.ic_results:
|
|
723
|
+
rows.append(
|
|
724
|
+
{
|
|
725
|
+
"feature": result.feature_name,
|
|
726
|
+
"mean_ic": result.mean_ic,
|
|
727
|
+
"ic_std": result.ic_std,
|
|
728
|
+
"ic_ir": result.ic_ir,
|
|
729
|
+
"pvalue": result.pvalue,
|
|
730
|
+
"hac_pvalue": result.hac_adjusted_pvalue,
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
return pl.DataFrame(rows)
|
|
734
|
+
|
|
735
|
+
def get_threshold_dataframe(self) -> pl.DataFrame:
|
|
736
|
+
"""Get threshold analysis as DataFrame.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
DataFrame with threshold metrics for all features
|
|
740
|
+
"""
|
|
741
|
+
if not self.threshold_results:
|
|
742
|
+
return pl.DataFrame()
|
|
743
|
+
|
|
744
|
+
dfs = [result.get_dataframe() for result in self.threshold_results]
|
|
745
|
+
return pl.concat(dfs)
|
|
746
|
+
|
|
747
|
+
def get_dataframe(self, name: str | None = None) -> pl.DataFrame:
|
|
748
|
+
"""Get results as DataFrame.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
name: 'ic' or 'threshold'
|
|
752
|
+
|
|
753
|
+
Returns:
|
|
754
|
+
Requested DataFrame
|
|
755
|
+
"""
|
|
756
|
+
if name == "threshold":
|
|
757
|
+
return self.get_threshold_dataframe()
|
|
758
|
+
else:
|
|
759
|
+
return self.get_ic_dataframe()
|
|
760
|
+
|
|
761
|
+
def summary(self) -> str:
|
|
762
|
+
"""Human-readable summary of feature-outcome relationships."""
|
|
763
|
+
lines = ["Feature-Outcome Analysis Summary", "=" * 40]
|
|
764
|
+
|
|
765
|
+
if self.ic_results:
|
|
766
|
+
lines.append(f"IC analysis: {len(self.ic_results)} features")
|
|
767
|
+
significant = sum(
|
|
768
|
+
1 for r in self.ic_results if r.hac_adjusted_pvalue and r.hac_adjusted_pvalue < 0.05
|
|
769
|
+
)
|
|
770
|
+
lines.append(f" Significant features: {significant}")
|
|
771
|
+
|
|
772
|
+
# Top features by IC
|
|
773
|
+
top = sorted(self.ic_results, key=lambda r: abs(r.mean_ic), reverse=True)[:3]
|
|
774
|
+
lines.append(" Top 3 by |IC|:")
|
|
775
|
+
for r in top:
|
|
776
|
+
lines.append(f" {r.feature_name}: IC={r.mean_ic:.4f}, IR={r.ic_ir:.4f}")
|
|
777
|
+
|
|
778
|
+
if self.threshold_results:
|
|
779
|
+
lines.append("")
|
|
780
|
+
lines.append(f"Threshold analysis: {len(self.threshold_results)} features")
|
|
781
|
+
# Top features by F1
|
|
782
|
+
top = sorted(self.threshold_results, key=lambda r: r.f1_score, reverse=True)[:3]
|
|
783
|
+
lines.append(" Top 3 by F1:")
|
|
784
|
+
for r in top:
|
|
785
|
+
lines.append(f" {r.feature_name}: F1={r.f1_score:.2%}, Lift={r.lift:.2f}x")
|
|
786
|
+
|
|
787
|
+
return "\n".join(lines)
|