import pandas as pd
import numpy as np
from pathlib import Path
from datetime import datetime
RESULTS_DIR = Path(__file__).parent / "results"
RUST_DIR = RESULTS_DIR / "rust"
TSFRESH_DIR = RESULTS_DIR / "tsfresh"
OUTPUT_DIR = Path(__file__).parent / "output"
RELATIVE_TOLERANCE = 1e-5 ABSOLUTE_TOLERANCE = 1e-9
def load_results() -> tuple[pd.DataFrame, pd.DataFrame]:
rust_df = pd.read_csv(RUST_DIR / "features.csv")
tsfresh_df = pd.read_csv(TSFRESH_DIR / "features.csv")
return rust_df, tsfresh_df
def normalize_feature_name(name: str) -> str:
name = name.lower()
name = name.strip()
return name
def compare_features(rust_df: pd.DataFrame, tsfresh_df: pd.DataFrame) -> pd.DataFrame:
rust_df = rust_df.copy()
tsfresh_df = tsfresh_df.copy()
rust_df["normalized_name"] = rust_df["feature_name"].apply(normalize_feature_name)
tsfresh_df["normalized_name"] = tsfresh_df["feature_name"].apply(normalize_feature_name)
merged = rust_df.merge(
tsfresh_df,
on=["series_type", "normalized_name"],
suffixes=("_rust", "_tsfresh"),
)
if merged.empty:
print("Warning: No matching features found between implementations")
return merged
merged["difference"] = merged["value_rust"] - merged["value_tsfresh"]
merged["abs_difference"] = merged["difference"].abs()
def compute_relative_diff(row):
rust_val = row["value_rust"]
tsfresh_val = row["value_tsfresh"]
if pd.isna(rust_val) and pd.isna(tsfresh_val):
return 0.0 if pd.isna(rust_val) or pd.isna(tsfresh_val):
return np.inf
if abs(tsfresh_val) > ABSOLUTE_TOLERANCE:
return abs(rust_val - tsfresh_val) / abs(tsfresh_val)
elif abs(rust_val - tsfresh_val) < ABSOLUTE_TOLERANCE:
return 0.0
else:
return np.inf
merged["relative_difference"] = merged.apply(compute_relative_diff, axis=1)
def check_match(row):
rust_val = row["value_rust"]
tsfresh_val = row["value_tsfresh"]
if pd.isna(rust_val) and pd.isna(tsfresh_val):
return True
if pd.isna(rust_val) or pd.isna(tsfresh_val):
return False
if np.isinf(rust_val) and np.isinf(tsfresh_val):
return np.sign(rust_val) == np.sign(tsfresh_val)
if abs(rust_val - tsfresh_val) < ABSOLUTE_TOLERANCE:
return True
if abs(tsfresh_val) > ABSOLUTE_TOLERANCE:
return abs(rust_val - tsfresh_val) / abs(tsfresh_val) < RELATIVE_TOLERANCE
return False
merged["matches"] = merged.apply(check_match, axis=1)
return merged
def compute_summary(comparison: pd.DataFrame) -> pd.DataFrame:
if comparison.empty:
return pd.DataFrame()
summary = comparison.groupby("normalized_name").agg({
"matches": ["sum", "count"],
"abs_difference": ["mean", "max"],
"relative_difference": ["mean", "max"],
}).reset_index()
summary.columns = [
"feature",
"matches",
"total",
"mean_abs_diff",
"max_abs_diff",
"mean_rel_diff",
"max_rel_diff",
]
summary["match_rate"] = summary["matches"] / summary["total"]
return summary.sort_values("match_rate")
def generate_markdown_report(
comparison: pd.DataFrame,
summary: pd.DataFrame,
rust_df: pd.DataFrame,
tsfresh_df: pd.DataFrame,
) -> str:
lines = [
"# Feature Validation Report",
"",
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
"## Summary",
"",
"- **Rust implementation**: anofox-forecast features module",
"- **Python implementation**: tsfresh",
"",
f"- **Total Rust features**: {len(rust_df)}",
f"- **Total tsfresh features**: {len(tsfresh_df)}",
f"- **Matched comparisons**: {len(comparison)}",
"",
]
if comparison.empty:
lines.extend([
"**Warning**: No matching features found between implementations.",
"",
"This may indicate a feature naming mismatch.",
"",
])
return "\n".join(lines)
total_matches = comparison["matches"].sum()
total_comparisons = len(comparison)
match_rate = total_matches / total_comparisons * 100
lines.extend([
f"### Overall Match Rate: {match_rate:.2f}%",
f"- Matching: {total_matches}/{total_comparisons}",
"",
])
perfect = summary[summary["match_rate"] == 1.0]
if len(perfect) > 0:
lines.extend([
"## Perfectly Matching Features",
"",
f"**{len(perfect)} features** have 100% match rate across all series types:",
"",
])
for _, row in perfect.head(20).iterrows():
lines.append(f"- `{row['feature']}`")
if len(perfect) > 20:
lines.append(f"- ... and {len(perfect) - 20} more")
lines.append("")
issues = summary[summary["match_rate"] < 1.0]
if len(issues) > 0:
lines.extend([
"## Features with Discrepancies",
"",
"| Feature | Match Rate | Mean Abs Diff | Max Abs Diff |",
"|---------|------------|---------------|--------------|",
])
for _, row in issues.iterrows():
lines.append(
f"| `{row['feature'][:50]}` | {row['match_rate']:.2%} | "
f"{row['mean_abs_diff']:.6f} | {row['max_abs_diff']:.6f} |"
)
lines.append("")
discrepancies = comparison[~comparison["matches"]].nlargest(20, "abs_difference")
if len(discrepancies) > 0:
lines.extend([
"## Largest Discrepancies (Top 20)",
"",
"| Series | Feature | Rust | tsfresh | Difference |",
"|--------|---------|------|---------|------------|",
])
for _, row in discrepancies.iterrows():
feature_short = row["normalized_name"][:40]
rust_val = f"{row['value_rust']:.6f}" if pd.notna(row["value_rust"]) else "NaN"
tsfresh_val = f"{row['value_tsfresh']:.6f}" if pd.notna(row["value_tsfresh"]) else "NaN"
diff_val = f"{row['difference']:.6f}" if pd.notna(row["difference"]) else "N/A"
lines.append(
f"| {row['series_type']} | `{feature_short}` | "
f"{rust_val} | {tsfresh_val} | {diff_val} |"
)
lines.append("")
by_series = comparison.groupby("series_type").agg({
"matches": ["sum", "count"]
}).reset_index()
by_series.columns = ["series_type", "matches", "total"]
by_series["match_rate"] = by_series["matches"] / by_series["total"]
lines.extend([
"## Match Rate by Series Type",
"",
"| Series Type | Match Rate | Matching/Total |",
"|-------------|------------|----------------|",
])
for _, row in by_series.iterrows():
lines.append(
f"| {row['series_type']} | {row['match_rate']:.2%} | "
f"{int(row['matches'])}/{int(row['total'])} |"
)
lines.extend([
"",
"## Notes",
"",
"Expected causes of discrepancies:",
"- Different numerical precision in implementations",
"- Different algorithm implementations (e.g., entropy calculations)",
"- Different handling of edge cases (empty series, constant values)",
"- Different default parameter values",
"- Boolean features may have different representations",
"",
f"Tolerance thresholds used:",
f"- Relative tolerance: {RELATIVE_TOLERANCE}",
f"- Absolute tolerance: {ABSOLUTE_TOLERANCE}",
"",
])
return "\n".join(lines)
def main():
print("=== Comparing Feature Results ===\n")
rust_file = RUST_DIR / "features.csv"
tsfresh_file = TSFRESH_DIR / "features.csv"
if not rust_file.exists():
print(f"Error: {rust_file} not found")
print("Run 'cargo run --example feature_export --release' first")
return
if not tsfresh_file.exists():
print(f"Error: {tsfresh_file} not found")
print("Run 'python extract_tsfresh_features.py' first")
return
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print("Loading results...")
rust_df, tsfresh_df = load_results()
print(f" Rust features: {len(rust_df)}")
print(f" tsfresh features: {len(tsfresh_df)}")
rust_unique = rust_df["feature_name"].nunique()
tsfresh_unique = tsfresh_df["feature_name"].nunique()
print(f" Rust unique features: {rust_unique}")
print(f" tsfresh unique features: {tsfresh_unique}")
print("\nComparing features...")
comparison = compare_features(rust_df, tsfresh_df)
print(f" Matched comparisons: {len(comparison)}")
if comparison.empty:
print("\nWarning: No features matched between implementations.")
print("This may indicate a naming convention mismatch.")
print("\nSample Rust feature names:")
for name in rust_df["feature_name"].head(10):
print(f" {name}")
print("\nSample tsfresh feature names:")
for name in tsfresh_df["feature_name"].head(10):
print(f" {name}")
return
print("Computing summary...")
summary = compute_summary(comparison)
print("\nGenerating reports...")
report_md = generate_markdown_report(comparison, summary, rust_df, tsfresh_df)
report_path = OUTPUT_DIR / "feature_report.md"
report_path.write_text(report_md)
print(f" Markdown report: {report_path}")
comparison_path = OUTPUT_DIR / "feature_comparison.csv"
comparison.to_csv(comparison_path, index=False)
print(f" Detailed comparison: {comparison_path}")
summary_path = OUTPUT_DIR / "feature_summary.csv"
summary.to_csv(summary_path, index=False)
print(f" Summary: {summary_path}")
match_rate = comparison["matches"].mean() * 100
print(f"\n=== Overall Match Rate: {match_rate:.2f}% ===")
perfect_count = len(summary[summary["match_rate"] == 1.0])
issue_count = len(summary[summary["match_rate"] < 1.0])
print(f" Perfect matches: {perfect_count} features")
print(f" With discrepancies: {issue_count} features")
worst = summary[summary["match_rate"] < 0.9].nlargest(5, "max_abs_diff")
if len(worst) > 0:
print("\nFeatures with largest discrepancies:")
for _, row in worst.iterrows():
print(f" - {row['feature']}: {row['match_rate']:.2%} match rate")
if __name__ == "__main__":
main()