
Data Storyteller
- 217 installs
- 84 repo stars
- Updated April 8, 2026
- dkyazzentwatwa/chatgpt-skills
Transform metrics, survey results, or dashboards into narrative insights, headlines, and stakeholder-ready stories for blogs, decks, and customer communications.
About
The data-storyteller skill converts raw analytics into clear narratives with takeaways, context, and audience-appropriate framing. It bridges numbers and communication for growth teams publishing insights in content, sales, and product marketing.
- Metric-to-narrative framing
- Audience-tailored insight arcs
- Chart and table explanation
- Headline and takeaway extraction
- Stakeholder-ready story outlines
Data Storyteller by the numbers
- 217 all-time installs (skills.sh)
- Ranked #631 of 2,065 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dkyazzentwatwa/chatgpt-skills --skill data-storytellerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 217 |
|---|---|
| repo stars | ★ 84 |
| Last updated | April 8, 2026 |
| Repository | dkyazzentwatwa/chatgpt-skills ↗ |
What it does
Transform metrics, survey results, or dashboards into narrative insights, headlines, and stakeholder-ready stories for blogs, decks, and customer communications.
Files
Data Storyteller
Use this as the primary analytics skill for structured data. It now absorbs the repo's audit, comparison, statistics, pivot, experiment, and time-series helpers.
Use This For
- Executive summaries and narrative reports from CSV or spreadsheet data
- Data quality audits, comparisons, and anomaly reviews
- Statistical analysis, pivots, experiment reads, ROI and budget analysis
- Survey summaries and time-series decomposition
Workflow
1. Profile the dataset shape, column types, and missing-value risk. 2. Pick the smallest useful analysis path instead of running every script by default. 3. Start with scripts/data_storyteller.py when the user wants a cohesive report. 4. Reach for focused helpers when the task is narrow:
data_quality_auditor.pydataset_comparer.pycorrelation_explorer.pyoutlier_detective.pystatistical_analyzer.pysurvey_analyzer.pyts_decomposer.pypivot_table_generator.pyab_test_calc.pyroi_calculator.pybudget_analyzer.py
5. Translate outputs into plain-English findings, risks, and next actions.
Guardrails
- Do not overstate causal claims from correlations.
- Call out data quality problems before presenting strong conclusions.
- Keep executive summaries short and move method detail behind them.
Bud1 tslg1Scomp @� @� @� @assetslg1ScompassetsmoDDblob�a#�w�AassetsmodDblob�a#�w�Aassetsph1Scomp
referenceslg1Scomp
referencesmoDDblob��a#�w�A
referencesmodDblob��a#�w�A
referencesph1Scompscriptsbwspblob�bplist00�]ShowStatusBar[ShowToolbar[ShowTabView_ContainerShowSidebar\WindowBounds[ShowSidebar _{{233, 189}, {920, 436}} #/;R_klmno�
�scriptslg1Scomp��scriptslsvCblobbbplist00�
XYZ[\]Z_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize � %).38=AFJNR�WvisibleUwidthYascendingZidentifier , Tname�UwidthYascendingWvisibleXubiquity#�"$ �\dateModified�"([dateCreated�+- aTsize�02 s Tkind�57d Ulabel�:<K Wversion�@ Xcomments�CE�^dateLastOpened�CIZshareOwner�CM_shareLastEditor�O"YdateAdded�TV�_invitationStatus##@.#�c�Tname#@02DL`u���������������!)2456?@BCPYZ[gpqsty����������������������������)*+4578KL]fox}^�scriptslsvpblob�bplist00�
HIJKLMJ_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������$-39CKMPQR[]_`ajlnopy{}~�����������������������������NscriptsmoDDblobV�u�w�AscriptsmodDblobV�u�w�Ascriptsph1Scomp�scriptsvSrnlong EDSDB `�(0@� @� @��!)2456?@BCPYZ[gpqsty����������������������������)*+4578KL]fox}^�scriptslsvpblob�bplist00�
HIJKLMJ_useRelativeDates_showIconPreviewWcolumns_calculateAllSizes_viewOptionsVersion_scrollPositionYXtextSize_scrollPositionXZsortColumnXiconSizeZaxTextSize �!&+059>BXcommentsUlabelWversion[dateCreatedTsize\dateModifiedTkindTname^dateLastOpened� UindexUwidthYascendingWvisible, �"# d �'( K �,- ��12 a �6- �:;s �? �CD �##@.#�c�Tname#@02DL`u��������������display_name: 'Data Storyteller'
short_description: 'Analyze datasets and produce concise narrative findings.'
default_prompt: 'Help me analyze this dataset and summarize the findings.'
#!/usr/bin/env python3
"""
A/B Test Calculator - Statistical significance testing for A/B experiments.
"""
import argparse
import json
import math
from typing import Dict, List, Tuple, Optional
import numpy as np
from scipy import stats
from scipy.stats import beta as beta_dist
class ABTestCalculator:
"""Calculate statistical significance for A/B tests."""
def __init__(self, alpha: float = 0.05):
"""
Initialize calculator.
Args:
alpha: Significance level (default 0.05 for 95% confidence)
"""
self.alpha = alpha
def test_significance(self, control_visitors: int, control_conversions: int,
variant_visitors: int, variant_conversions: int,
test: str = "chi_square") -> Dict:
"""
Test statistical significance between control and variant.
Args:
control_visitors: Number of visitors in control group
control_conversions: Number of conversions in control group
variant_visitors: Number of visitors in variant group
variant_conversions: Number of conversions in variant group
test: Test method ("chi_square", "z_test")
Returns:
Dictionary with test results
"""
control_rate = control_conversions / control_visitors
variant_rate = variant_conversions / variant_visitors
lift = (variant_rate - control_rate) / control_rate if control_rate > 0 else 0
lift_absolute = variant_rate - control_rate
if test == "chi_square":
p_value = self._chi_square_test(
control_visitors, control_conversions,
variant_visitors, variant_conversions
)
elif test == "z_test":
p_value = self._z_test_proportions(
control_visitors, control_conversions,
variant_visitors, variant_conversions
)
else:
raise ValueError(f"Unknown test method: {test}")
# Calculate confidence interval for lift
ci = self._lift_confidence_interval(
control_visitors, control_conversions,
variant_visitors, variant_conversions
)
significant = p_value < self.alpha
if significant:
if lift > 0:
recommendation = "Variant shows significant improvement. Consider implementing."
else:
recommendation = "Variant shows significant decrease. Keep control."
else:
recommendation = "No significant difference detected. Need more data or larger effect."
return {
"significant": significant,
"p_value": p_value,
"control_rate": control_rate,
"variant_rate": variant_rate,
"lift": lift,
"lift_absolute": lift_absolute,
"confidence_interval": ci,
"test_method": test,
"alpha": self.alpha,
"recommendation": recommendation
}
def _chi_square_test(self, c_visitors: int, c_conv: int,
v_visitors: int, v_conv: int) -> float:
"""Perform chi-square test for independence."""
# Contingency table
observed = np.array([
[c_conv, c_visitors - c_conv],
[v_conv, v_visitors - v_conv]
])
chi2, p_value, dof, expected = stats.chi2_contingency(observed)
return p_value
def _z_test_proportions(self, c_visitors: int, c_conv: int,
v_visitors: int, v_conv: int) -> float:
"""Perform Z-test for two proportions."""
p1 = c_conv / c_visitors
p2 = v_conv / v_visitors
n1 = c_visitors
n2 = v_visitors
# Pooled proportion
p_pooled = (c_conv + v_conv) / (n1 + n2)
# Standard error
se = math.sqrt(p_pooled * (1 - p_pooled) * (1/n1 + 1/n2))
if se == 0:
return 1.0
# Z statistic
z = (p2 - p1) / se
# Two-tailed p-value
p_value = 2 * (1 - stats.norm.cdf(abs(z)))
return p_value
def _lift_confidence_interval(self, c_visitors: int, c_conv: int,
v_visitors: int, v_conv: int,
confidence: float = 0.95) -> Dict:
"""Calculate confidence interval for relative lift."""
p1 = c_conv / c_visitors
p2 = v_conv / v_visitors
# Standard errors
se1 = math.sqrt(p1 * (1 - p1) / c_visitors)
se2 = math.sqrt(p2 * (1 - p2) / v_visitors)
# SE of difference
se_diff = math.sqrt(se1**2 + se2**2)
# Z value for confidence level
z = stats.norm.ppf((1 + confidence) / 2)
diff = p2 - p1
margin = z * se_diff
# Convert to relative lift if control rate > 0
if p1 > 0:
lower = (diff - margin) / p1
upper = (diff + margin) / p1
else:
lower = 0
upper = 0
return {
"lower": lower,
"upper": upper,
"confidence_level": confidence
}
def calculate_sample_size(self, baseline_rate: float,
minimum_detectable_effect: float,
power: float = 0.8,
alpha: float = None) -> Dict:
"""
Calculate required sample size per variant.
Args:
baseline_rate: Current conversion rate (e.g., 0.05 for 5%)
minimum_detectable_effect: Relative change to detect (e.g., 0.10 for 10%)
power: Statistical power (default 0.8 for 80%)
alpha: Significance level (uses instance default if not specified)
Returns:
Dictionary with sample size information
"""
if alpha is None:
alpha = self.alpha
p1 = baseline_rate
p2 = baseline_rate * (1 + minimum_detectable_effect)
# Z values
z_alpha = stats.norm.ppf(1 - alpha/2)
z_beta = stats.norm.ppf(power)
# Pooled proportion
p_pooled = (p1 + p2) / 2
# Sample size formula
numerator = (z_alpha * math.sqrt(2 * p_pooled * (1 - p_pooled)) +
z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
denominator = (p2 - p1) ** 2
if denominator == 0:
sample_size = float('inf')
else:
sample_size = math.ceil(numerator / denominator)
return {
"sample_size_per_variant": sample_size,
"total_sample_size": sample_size * 2,
"baseline_rate": baseline_rate,
"expected_variant_rate": p2,
"minimum_detectable_effect": minimum_detectable_effect,
"power": power,
"alpha": alpha
}
def calculate_power(self, baseline_rate: float,
minimum_detectable_effect: float,
sample_size: int,
alpha: float = None) -> Dict:
"""
Calculate statistical power given sample size.
Args:
baseline_rate: Current conversion rate
minimum_detectable_effect: Relative change to detect
sample_size: Sample size per variant
alpha: Significance level
Returns:
Dictionary with power analysis
"""
if alpha is None:
alpha = self.alpha
p1 = baseline_rate
p2 = baseline_rate * (1 + minimum_detectable_effect)
# Standard error
se = math.sqrt(p1 * (1 - p1) / sample_size + p2 * (1 - p2) / sample_size)
# Z value for alpha
z_alpha = stats.norm.ppf(1 - alpha/2)
# Calculate power
z_beta = (abs(p2 - p1) / se) - z_alpha
power = stats.norm.cdf(z_beta)
return {
"power": power,
"sample_size_per_variant": sample_size,
"baseline_rate": baseline_rate,
"minimum_detectable_effect": minimum_detectable_effect,
"alpha": alpha,
"interpretation": f"{power:.0%} chance of detecting the effect if it exists"
}
def confidence_interval(self, visitors: int, conversions: int,
confidence: float = 0.95) -> Dict:
"""
Calculate confidence interval for a conversion rate.
Args:
visitors: Total visitors
conversions: Number of conversions
confidence: Confidence level (default 0.95)
Returns:
Dictionary with CI bounds
"""
rate = conversions / visitors
# Wilson score interval (better for small samples)
z = stats.norm.ppf((1 + confidence) / 2)
denominator = 1 + z**2 / visitors
center = (rate + z**2 / (2 * visitors)) / denominator
margin = z * math.sqrt((rate * (1 - rate) + z**2 / (4 * visitors)) / visitors) / denominator
return {
"rate": rate,
"lower": max(0, center - margin),
"upper": min(1, center + margin),
"confidence_level": confidence,
"method": "wilson"
}
def bayesian_analysis(self, control_visitors: int, control_conversions: int,
variant_visitors: int, variant_conversions: int,
simulations: int = 100000) -> Dict:
"""
Bayesian analysis using Beta distributions.
Args:
control_visitors: Number of visitors in control
control_conversions: Number of conversions in control
variant_visitors: Number of visitors in variant
variant_conversions: Number of conversions in variant
simulations: Number of Monte Carlo simulations
Returns:
Dictionary with Bayesian analysis results
"""
# Beta distribution parameters (using uniform prior)
alpha_c = control_conversions + 1
beta_c = control_visitors - control_conversions + 1
alpha_v = variant_conversions + 1
beta_v = variant_visitors - variant_conversions + 1
# Sample from posterior distributions
control_samples = np.random.beta(alpha_c, beta_c, simulations)
variant_samples = np.random.beta(alpha_v, beta_v, simulations)
# Probability variant beats control
prob_variant_better = np.mean(variant_samples > control_samples)
# Expected lift
lift_samples = (variant_samples - control_samples) / control_samples
expected_lift = np.mean(lift_samples)
# Credible interval for lift
ci_lower = np.percentile(lift_samples, 2.5)
ci_upper = np.percentile(lift_samples, 97.5)
return {
"prob_variant_better": prob_variant_better,
"prob_control_better": 1 - prob_variant_better,
"expected_lift": expected_lift,
"credible_interval_95": [ci_lower, ci_upper],
"control_rate_mean": np.mean(control_samples),
"variant_rate_mean": np.mean(variant_samples)
}
def test_multiple_variants(self, control: Tuple[int, int],
variants: List[Tuple[int, int]],
correction: str = "bonferroni") -> Dict:
"""
Test multiple variants against control with correction.
Args:
control: (visitors, conversions) for control
variants: List of (visitors, conversions) tuples for variants
correction: Multiple testing correction ("bonferroni", "holm", "none")
Returns:
Dictionary with all variant comparisons
"""
c_visitors, c_conversions = control
c_rate = c_conversions / c_visitors
n_variants = len(variants)
results = []
p_values = []
# Run tests for each variant
for i, (v_visitors, v_conversions) in enumerate(variants):
v_rate = v_conversions / v_visitors
lift = (v_rate - c_rate) / c_rate if c_rate > 0 else 0
p_value = self._chi_square_test(
c_visitors, c_conversions,
v_visitors, v_conversions
)
p_values.append(p_value)
results.append({
"name": f"Variant {chr(65 + i)}",
"visitors": v_visitors,
"conversions": v_conversions,
"rate": v_rate,
"lift": lift,
"p_value": p_value
})
# Apply correction
if correction == "bonferroni":
adjusted_alpha = self.alpha / n_variants
for r in results:
r["significant"] = r["p_value"] < adjusted_alpha
elif correction == "holm":
# Holm-Bonferroni method
sorted_indices = np.argsort(p_values)
for rank, idx in enumerate(sorted_indices):
adjusted_alpha = self.alpha / (n_variants - rank)
results[idx]["significant"] = p_values[idx] < adjusted_alpha
else:
for r in results:
r["significant"] = r["p_value"] < self.alpha
# Find winner
significant_results = [r for r in results if r["significant"] and r["lift"] > 0]
if significant_results:
winner = max(significant_results, key=lambda x: x["lift"])["name"]
else:
winner = "Control (no significant improvement)"
return {
"control": {
"visitors": c_visitors,
"conversions": c_conversions,
"rate": c_rate
},
"variants": results,
"winner": winner,
"correction_method": correction,
"alpha": self.alpha
}
def estimate_duration(self, daily_visitors: int, baseline_rate: float,
minimum_detectable_effect: float,
power: float = 0.8) -> Dict:
"""
Estimate test duration based on traffic.
Args:
daily_visitors: Average daily visitors
baseline_rate: Current conversion rate
minimum_detectable_effect: Relative change to detect
power: Desired statistical power
Returns:
Dictionary with duration estimates
"""
sample = self.calculate_sample_size(
baseline_rate, minimum_detectable_effect, power
)
# Assuming 50/50 split
visitors_per_variant_per_day = daily_visitors / 2
if visitors_per_variant_per_day > 0:
days = math.ceil(sample["sample_size_per_variant"] / visitors_per_variant_per_day)
else:
days = float('inf')
return {
"days": days,
"weeks": math.ceil(days / 7),
"sample_size_per_variant": sample["sample_size_per_variant"],
"daily_visitors": daily_visitors,
"visitors_per_variant_per_day": visitors_per_variant_per_day
}
def main():
parser = argparse.ArgumentParser(
description="A/B Test Calculator - Statistical significance testing"
)
parser.add_argument("--test", nargs=4, type=int, metavar=("CV", "CC", "VV", "VC"),
help="Test significance: control_visitors control_conversions variant_visitors variant_conversions")
parser.add_argument("--sample-size", action="store_true",
help="Calculate sample size")
parser.add_argument("--power-analysis", action="store_true",
help="Perform power analysis")
parser.add_argument("--bayesian", nargs=4, type=int, metavar=("CV", "CC", "VV", "VC"),
help="Bayesian analysis: control_visitors control_conversions variant_visitors variant_conversions")
parser.add_argument("--test-multi", nargs="+", type=int,
help="Test multiple variants: c_visitors c_conv v1_visitors v1_conv [v2_visitors v2_conv ...]")
parser.add_argument("--baseline", type=float, help="Baseline conversion rate (e.g., 0.05)")
parser.add_argument("--mde", type=float, help="Minimum detectable effect (e.g., 0.10)")
parser.add_argument("--power", type=float, default=0.8, help="Statistical power (default: 0.8)")
parser.add_argument("--samples", type=int, help="Sample size per variant")
parser.add_argument("--alpha", type=float, default=0.05, help="Significance level (default: 0.05)")
parser.add_argument("--method", choices=["chi_square", "z_test"], default="chi_square",
help="Test method (default: chi_square)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
calc = ABTestCalculator(alpha=args.alpha)
if args.test:
result = calc.test_significance(
args.test[0], args.test[1],
args.test[2], args.test[3],
test=args.method
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n=== A/B Test Results ===")
print(f"Control: {args.test[1]}/{args.test[0]} ({result['control_rate']:.2%})")
print(f"Variant: {args.test[3]}/{args.test[2]} ({result['variant_rate']:.2%})")
print(f"\nLift: {result['lift']:+.2%} ({result['lift_absolute']:+.4f} absolute)")
print(f"P-value: {result['p_value']:.4f}")
print(f"Significant: {'Yes' if result['significant'] else 'No'} (alpha={args.alpha})")
print(f"\n95% CI for lift: [{result['confidence_interval']['lower']:.2%}, {result['confidence_interval']['upper']:.2%}]")
print(f"\n{result['recommendation']}")
elif args.sample_size:
if not args.baseline or not args.mde:
parser.error("--sample-size requires --baseline and --mde")
result = calc.calculate_sample_size(
args.baseline, args.mde, args.power
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n=== Sample Size Calculation ===")
print(f"Baseline rate: {result['baseline_rate']:.2%}")
print(f"Expected variant rate: {result['expected_variant_rate']:.2%}")
print(f"Minimum detectable effect: {result['minimum_detectable_effect']:.1%}")
print(f"Power: {result['power']:.0%}")
print(f"Alpha: {result['alpha']}")
print(f"\nRequired sample size per variant: {result['sample_size_per_variant']:,}")
print(f"Total sample size (both variants): {result['total_sample_size']:,}")
elif args.power_analysis:
if not args.baseline or not args.mde or not args.samples:
parser.error("--power-analysis requires --baseline, --mde, and --samples")
result = calc.calculate_power(
args.baseline, args.mde, args.samples
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n=== Power Analysis ===")
print(f"Sample size per variant: {result['sample_size_per_variant']:,}")
print(f"Baseline rate: {result['baseline_rate']:.2%}")
print(f"MDE: {result['minimum_detectable_effect']:.1%}")
print(f"\nStatistical Power: {result['power']:.1%}")
print(f"({result['interpretation']})")
elif args.bayesian:
result = calc.bayesian_analysis(
args.bayesian[0], args.bayesian[1],
args.bayesian[2], args.bayesian[3]
)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n=== Bayesian Analysis ===")
print(f"Control rate: {result['control_rate_mean']:.2%}")
print(f"Variant rate: {result['variant_rate_mean']:.2%}")
print(f"\nProbability variant beats control: {result['prob_variant_better']:.1%}")
print(f"Expected lift: {result['expected_lift']:+.2%}")
print(f"95% Credible interval: [{result['credible_interval_95'][0]:.2%}, {result['credible_interval_95'][1]:.2%}]")
elif args.test_multi:
if len(args.test_multi) < 4 or len(args.test_multi) % 2 != 0:
parser.error("--test-multi requires pairs of (visitors, conversions)")
control = (args.test_multi[0], args.test_multi[1])
variants = []
for i in range(2, len(args.test_multi), 2):
variants.append((args.test_multi[i], args.test_multi[i+1]))
result = calc.test_multiple_variants(control, variants)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n=== Multiple Variant Test ===")
print(f"Control: {result['control']['conversions']}/{result['control']['visitors']} ({result['control']['rate']:.2%})")
print(f"\nVariants:")
for v in result['variants']:
sig = "*" if v['significant'] else ""
print(f" {v['name']}: {v['conversions']}/{v['visitors']} ({v['rate']:.2%}) "
f"Lift: {v['lift']:+.2%} p={v['p_value']:.4f}{sig}")
print(f"\nWinner: {result['winner']}")
print(f"(Correction: {result['correction_method']})")
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Budget Analyzer - Analyze expenses, track budgets, get savings recommendations.
"""
import argparse
import json
import re
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from io import BytesIO
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg')
class BudgetAnalyzer:
"""Analyze personal or business expenses."""
DEFAULT_CATEGORIES = {
"Food & Dining": ["restaurant", "cafe", "starbucks", "mcdonald", "uber eats",
"doordash", "grubhub", "chipotle", "subway", "pizza", "food"],
"Transportation": ["uber", "lyft", "gas", "shell", "chevron", "parking",
"transit", "metro", "bus", "taxi", "fuel"],
"Shopping": ["amazon", "walmart", "target", "costco", "best buy", "ebay",
"etsy", "shop", "store", "market"],
"Utilities": ["electric", "water", "gas bill", "internet", "phone", "verizon",
"at&t", "comcast", "utility", "power"],
"Entertainment": ["netflix", "spotify", "hulu", "disney", "movie", "theater",
"concert", "game", "steam"],
"Healthcare": ["pharmacy", "cvs", "walgreens", "doctor", "hospital", "medical",
"dental", "vision", "health"],
"Travel": ["airline", "hotel", "airbnb", "booking", "flight", "vacation",
"resort", "travel"],
"Subscriptions": ["subscription", "membership", "monthly", "annual", "premium"],
"Insurance": ["insurance", "geico", "state farm", "allstate", "progressive"],
"Education": ["tuition", "school", "university", "course", "book", "udemy"],
}
def __init__(self):
"""Initialize the analyzer."""
self.df = None
self.categories = self.DEFAULT_CATEGORIES.copy()
self.budget = {}
self.date_col = "date"
self.amount_col = "amount"
self.description_col = "description"
self.category_col = "category"
def load_csv(self, filepath: str, date_col: str, amount_col: str,
description_col: str = None, category_col: str = None) -> 'BudgetAnalyzer':
"""Load transaction data from CSV."""
self.df = pd.read_csv(filepath)
self.date_col = date_col
self.amount_col = amount_col
self.description_col = description_col or "description"
self.category_col = category_col or "category"
# Parse dates
self.df[self.date_col] = pd.to_datetime(self.df[self.date_col])
# Ensure amount is numeric and positive (expenses)
self.df[self.amount_col] = pd.to_numeric(self.df[self.amount_col], errors='coerce').abs()
# Add category column if not present
if self.category_col not in self.df.columns:
self.df[self.category_col] = "Uncategorized"
return self
def load_dataframe(self, df: pd.DataFrame, date_col: str = "date",
amount_col: str = "amount", description_col: str = "description",
category_col: str = "category") -> 'BudgetAnalyzer':
"""Load from existing DataFrame."""
self.df = df.copy()
self.date_col = date_col
self.amount_col = amount_col
self.description_col = description_col
self.category_col = category_col
self.df[self.date_col] = pd.to_datetime(self.df[self.date_col])
self.df[self.amount_col] = pd.to_numeric(self.df[self.amount_col], errors='coerce').abs()
if self.category_col not in self.df.columns:
self.df[self.category_col] = "Uncategorized"
return self
def set_categories(self, categories: Dict[str, List[str]]) -> 'BudgetAnalyzer':
"""Set custom category mappings."""
self.categories = categories
return self
def auto_categorize(self) -> 'BudgetAnalyzer':
"""Auto-categorize transactions based on description."""
if self.description_col not in self.df.columns:
return self
def categorize(description: str) -> str:
if pd.isna(description):
return "Uncategorized"
desc_lower = str(description).lower()
for category, keywords in self.categories.items():
for keyword in keywords:
if keyword.lower() in desc_lower:
return category
return "Other"
self.df[self.category_col] = self.df[self.description_col].apply(categorize)
return self
def set_budget(self, budget: Dict[str, float]) -> 'BudgetAnalyzer':
"""Set budget targets by category."""
self.budget = budget
return self
def analyze(self) -> Dict:
"""Get full analysis summary."""
if self.df is None or len(self.df) == 0:
return {}
total = self.df[self.amount_col].sum()
count = len(self.df)
avg = self.df[self.amount_col].mean()
largest_idx = self.df[self.amount_col].idxmax()
largest = self.df.loc[largest_idx]
categories = self.df.groupby(self.category_col)[self.amount_col].sum().to_dict()
return {
"total_spent": round(total, 2),
"transaction_count": count,
"date_range": {
"start": self.df[self.date_col].min().strftime("%Y-%m-%d"),
"end": self.df[self.date_col].max().strftime("%Y-%m-%d")
},
"average_transaction": round(avg, 2),
"median_transaction": round(self.df[self.amount_col].median(), 2),
"largest_expense": {
"amount": round(largest[self.amount_col], 2),
"description": largest.get(self.description_col, "N/A") if self.description_col in self.df.columns else "N/A",
"date": largest[self.date_col].strftime("%Y-%m-%d")
},
"categories": {k: round(v, 2) for k, v in categories.items()}
}
def by_category(self) -> pd.DataFrame:
"""Get spending breakdown by category."""
if self.df is None:
return pd.DataFrame()
grouped = self.df.groupby(self.category_col).agg({
self.amount_col: ['sum', 'count', 'mean']
}).reset_index()
grouped.columns = ['category', 'amount', 'count', 'average']
total = grouped['amount'].sum()
grouped['percentage'] = (grouped['amount'] / total * 100).round(1)
grouped = grouped.sort_values('amount', ascending=False)
grouped['amount'] = grouped['amount'].round(2)
grouped['average'] = grouped['average'].round(2)
return grouped
def by_month(self) -> pd.DataFrame:
"""Get spending by month."""
if self.df is None:
return pd.DataFrame()
df = self.df.copy()
df['month'] = df[self.date_col].dt.to_period('M')
grouped = df.groupby('month').agg({
self.amount_col: ['sum', 'count', 'mean']
}).reset_index()
grouped.columns = ['month', 'total', 'count', 'avg_transaction']
grouped['month'] = grouped['month'].astype(str)
grouped['total'] = grouped['total'].round(2)
grouped['avg_transaction'] = grouped['avg_transaction'].round(2)
return grouped
def by_day_of_week(self) -> pd.DataFrame:
"""Get spending by day of week."""
if self.df is None:
return pd.DataFrame()
df = self.df.copy()
df['day_of_week'] = df[self.date_col].dt.day_name()
grouped = df.groupby('day_of_week').agg({
self.amount_col: ['sum', 'count', 'mean']
}).reset_index()
grouped.columns = ['day', 'total', 'count', 'average']
# Sort by day of week
days_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
grouped['day'] = pd.Categorical(grouped['day'], categories=days_order, ordered=True)
grouped = grouped.sort_values('day')
grouped['total'] = grouped['total'].round(2)
grouped['average'] = grouped['average'].round(2)
return grouped
def top_expenses(self, n: int = 10) -> pd.DataFrame:
"""Get top N expenses."""
if self.df is None:
return pd.DataFrame()
cols = [self.date_col, self.amount_col]
if self.description_col in self.df.columns:
cols.append(self.description_col)
if self.category_col in self.df.columns:
cols.append(self.category_col)
return self.df.nlargest(n, self.amount_col)[cols].reset_index(drop=True)
def recurring_expenses(self, min_occurrences: int = 2) -> pd.DataFrame:
"""Identify recurring expenses."""
if self.df is None or self.description_col not in self.df.columns:
return pd.DataFrame()
# Group by similar descriptions
grouped = self.df.groupby(self.description_col).agg({
self.amount_col: ['count', 'sum', 'mean', 'std']
}).reset_index()
grouped.columns = ['description', 'occurrences', 'total', 'average', 'std_dev']
# Filter for recurring (multiple occurrences, similar amounts)
recurring = grouped[grouped['occurrences'] >= min_occurrences].copy()
recurring['likely_subscription'] = recurring['std_dev'].fillna(0) < (recurring['average'] * 0.1)
recurring = recurring.sort_values('occurrences', ascending=False)
return recurring
def compare_periods(self, period1: str, period2: str) -> Dict:
"""Compare spending between two periods."""
if self.df is None:
return {}
df = self.df.copy()
df['period'] = df[self.date_col].dt.to_period('M').astype(str)
# Support quarter format
if 'Q' in period1:
df['period'] = df[self.date_col].dt.to_period('Q').astype(str)
p1_df = df[df['period'] == period1]
p2_df = df[df['period'] == period2]
p1_total = p1_df[self.amount_col].sum()
p2_total = p2_df[self.amount_col].sum()
diff = p2_total - p1_total
pct_change = (diff / p1_total * 100) if p1_total > 0 else 0
# Category comparison
p1_cats = p1_df.groupby(self.category_col)[self.amount_col].sum()
p2_cats = p2_df.groupby(self.category_col)[self.amount_col].sum()
all_cats = set(p1_cats.index) | set(p2_cats.index)
category_changes = {}
for cat in all_cats:
p1_val = p1_cats.get(cat, 0)
p2_val = p2_cats.get(cat, 0)
change = p2_val - p1_val
pct = (change / p1_val * 100) if p1_val > 0 else (100 if p2_val > 0 else 0)
category_changes[cat] = {
"period1": round(p1_val, 2),
"period2": round(p2_val, 2),
"change": round(change, 2),
"percent": round(pct, 1)
}
return {
"period1": period1,
"period2": period2,
"period1_total": round(p1_total, 2),
"period2_total": round(p2_total, 2),
"difference": round(diff, 2),
"percent_change": round(pct_change, 1),
"category_changes": category_changes
}
def budget_vs_actual(self) -> pd.DataFrame:
"""Compare actual spending to budget."""
if not self.budget or self.df is None:
return pd.DataFrame()
actual = self.df.groupby(self.category_col)[self.amount_col].sum()
data = []
for category, budget_amount in self.budget.items():
actual_amount = actual.get(category, 0)
diff = budget_amount - actual_amount
status = "under" if diff >= 0 else "over"
if diff >= 0 and actual_amount >= budget_amount * 0.9:
status = "warning"
data.append({
"category": category,
"budget": budget_amount,
"actual": round(actual_amount, 2),
"difference": round(diff, 2),
"percent_used": round(actual_amount / budget_amount * 100, 1) if budget_amount > 0 else 0,
"status": status
})
return pd.DataFrame(data).sort_values("actual", ascending=False)
def budget_alerts(self) -> List[Dict]:
"""Get budget alerts for categories over or near budget."""
comparison = self.budget_vs_actual()
if comparison.empty:
return []
alerts = []
for _, row in comparison.iterrows():
if row['status'] == 'over':
alerts.append({
"category": row['category'],
"status": "over",
"actual": row['actual'],
"budget": row['budget'],
"percent_over": round((row['actual'] - row['budget']) / row['budget'] * 100, 1)
})
elif row['status'] == 'warning':
alerts.append({
"category": row['category'],
"status": "warning",
"actual": row['actual'],
"budget": row['budget'],
"percent_used": row['percent_used']
})
return alerts
def get_recommendations(self) -> List[str]:
"""Get spending recommendations."""
if self.df is None:
return []
recommendations = []
# Category analysis
categories = self.by_category()
if not categories.empty:
top_cat = categories.iloc[0]
recommendations.append(
f"Top spending category: {top_cat['category']} (${top_cat['amount']:.2f}, "
f"{top_cat['percentage']:.1f}% of total)"
)
# Month-over-month changes
monthly = self.by_month()
if len(monthly) >= 2:
last_two = monthly.tail(2)
if len(last_two) == 2:
change = last_two.iloc[1]['total'] - last_two.iloc[0]['total']
pct = (change / last_two.iloc[0]['total'] * 100) if last_two.iloc[0]['total'] > 0 else 0
if pct > 10:
recommendations.append(
f"Spending increased {pct:.1f}% from {last_two.iloc[0]['month']} to {last_two.iloc[1]['month']}. "
"Review recent purchases for unnecessary expenses."
)
elif pct < -10:
recommendations.append(
f"Great job! Spending decreased {abs(pct):.1f}% from {last_two.iloc[0]['month']}."
)
# Recurring expenses
recurring = self.recurring_expenses()
if not recurring.empty:
subscriptions = recurring[recurring['likely_subscription'] == True]
if not subscriptions.empty:
total_subs = subscriptions['average'].sum()
recommendations.append(
f"Found {len(subscriptions)} potential subscriptions totaling ~${total_subs:.2f}/period. "
"Review for unused services."
)
# Budget alerts
alerts = self.budget_alerts()
for alert in alerts:
if alert['status'] == 'over':
recommendations.append(
f"{alert['category']} is {alert['percent_over']:.1f}% over budget. "
f"Actual: ${alert['actual']:.2f}, Budget: ${alert['budget']:.2f}"
)
# Day of week patterns
by_day = self.by_day_of_week()
if not by_day.empty:
highest_day = by_day.loc[by_day['total'].idxmax()]
recommendations.append(
f"Highest spending day: {highest_day['day']} (${highest_day['total']:.2f}). "
"Consider planning purchases for other days."
)
return recommendations
def spending_score(self) -> Dict:
"""Calculate overall spending health score."""
if self.df is None:
return {}
scores = []
# Budget adherence (if budget set)
if self.budget:
comparison = self.budget_vs_actual()
if not comparison.empty:
under_budget = len(comparison[comparison['status'] == 'under'])
adherence = (under_budget / len(comparison)) * 100
scores.append(('budget_adherence', adherence))
# Spending consistency (low variance = more consistent)
monthly = self.by_month()
if len(monthly) >= 3:
cv = monthly['total'].std() / monthly['total'].mean() if monthly['total'].mean() > 0 else 0
consistency = max(0, 100 - cv * 100)
scores.append(('spending_consistency', consistency))
# Category diversity (not too concentrated)
categories = self.by_category()
if not categories.empty:
top_pct = categories.iloc[0]['percentage']
diversity = max(0, 100 - (top_pct - 30)) # Penalty if top category > 30%
scores.append(('category_diversity', diversity))
if not scores:
return {"overall_score": 50, "grade": "N/A", "summary": "Insufficient data"}
overall = np.mean([s[1] for s in scores])
grade = 'A' if overall >= 90 else 'B' if overall >= 75 else 'C' if overall >= 60 else 'D' if overall >= 40 else 'F'
return {
"overall_score": round(overall),
"factors": {name: round(score) for name, score in scores},
"grade": grade,
"summary": self._get_score_summary(grade)
}
def _get_score_summary(self, grade: str) -> str:
"""Get summary text for spending score."""
summaries = {
'A': "Excellent spending habits. Keep up the great work!",
'B': "Good spending habits with minor room for improvement.",
'C': "Average spending habits. Consider reviewing your budget.",
'D': "Below average. Significant opportunities to improve spending.",
'F': "Needs attention. Consider creating a strict budget plan."
}
return summaries.get(grade, "Unable to assess spending habits.")
def plot_categories(self, output: str) -> str:
"""Create pie chart of spending by category."""
categories = self.by_category()
if categories.empty:
return ""
fig, ax = plt.subplots(figsize=(10, 8))
# Limit to top 8 categories, group rest as "Other"
if len(categories) > 8:
top = categories.head(7)
other_total = categories.iloc[7:]['amount'].sum()
other_row = pd.DataFrame([{
'category': 'Other',
'amount': other_total,
'percentage': categories.iloc[7:]['percentage'].sum()
}])
categories = pd.concat([top, other_row], ignore_index=True)
colors = plt.cm.Set3(np.linspace(0, 1, len(categories)))
wedges, texts, autotexts = ax.pie(
categories['amount'],
labels=categories['category'],
autopct='%1.1f%%',
colors=colors,
startangle=90
)
ax.set_title('Spending by Category', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def plot_trends(self, output: str) -> str:
"""Create line chart of monthly spending."""
monthly = self.by_month()
if monthly.empty:
return ""
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(monthly['month'], monthly['total'], marker='o', linewidth=2, markersize=8)
ax.fill_between(monthly['month'], monthly['total'], alpha=0.3)
ax.set_xlabel('Month', fontsize=12)
ax.set_ylabel('Total Spending ($)', fontsize=12)
ax.set_title('Monthly Spending Trend', fontsize=14, fontweight='bold')
plt.xticks(rotation=45, ha='right')
ax.grid(True, alpha=0.3)
# Add average line
avg = monthly['total'].mean()
ax.axhline(y=avg, color='red', linestyle='--', alpha=0.7, label=f'Average: ${avg:.2f}')
ax.legend()
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def plot_budget_comparison(self, output: str) -> str:
"""Create bar chart comparing budget vs actual."""
comparison = self.budget_vs_actual()
if comparison.empty:
return ""
fig, ax = plt.subplots(figsize=(12, 6))
x = np.arange(len(comparison))
width = 0.35
bars1 = ax.bar(x - width/2, comparison['budget'], width, label='Budget', color='steelblue')
bars2 = ax.bar(x + width/2, comparison['actual'], width, label='Actual', color='coral')
ax.set_xlabel('Category', fontsize=12)
ax.set_ylabel('Amount ($)', fontsize=12)
ax.set_title('Budget vs Actual Spending', fontsize=14, fontweight='bold')
ax.set_xticks(x)
ax.set_xticklabels(comparison['category'], rotation=45, ha='right')
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def generate_report(self, output: str, format: str = "pdf") -> str:
"""Generate comprehensive report."""
if format == "html":
return self._generate_html_report(output)
else:
return self._generate_pdf_report(output)
def _generate_pdf_report(self, output: str) -> str:
"""Generate PDF report using reportlab."""
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image
doc = SimpleDocTemplate(output, pagesize=letter)
styles = getSampleStyleSheet()
story = []
# Title
title_style = ParagraphStyle(
'Title',
parent=styles['Heading1'],
fontSize=24,
spaceAfter=30
)
story.append(Paragraph("Budget Analysis Report", title_style))
story.append(Spacer(1, 20))
# Summary
summary = self.analyze()
if summary:
story.append(Paragraph("Executive Summary", styles['Heading2']))
summary_text = f"""
<b>Total Spent:</b> ${summary['total_spent']:,.2f}<br/>
<b>Transactions:</b> {summary['transaction_count']}<br/>
<b>Average Transaction:</b> ${summary['average_transaction']:,.2f}<br/>
<b>Date Range:</b> {summary['date_range']['start']} to {summary['date_range']['end']}<br/>
<b>Largest Expense:</b> ${summary['largest_expense']['amount']:,.2f}
"""
story.append(Paragraph(summary_text, styles['Normal']))
story.append(Spacer(1, 20))
# Category breakdown
categories = self.by_category()
if not categories.empty:
story.append(Paragraph("Spending by Category", styles['Heading2']))
table_data = [['Category', 'Amount', 'Percentage', 'Count']]
for _, row in categories.head(10).iterrows():
table_data.append([
row['category'],
f"${row['amount']:,.2f}",
f"{row['percentage']:.1f}%",
str(int(row['count']))
])
table = Table(table_data, colWidths=[2*inch, 1.5*inch, 1*inch, 1*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.steelblue),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 12),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
('GRID', (0, 0), (-1, -1), 1, colors.black)
]))
story.append(table)
story.append(Spacer(1, 20))
# Budget comparison
budget_comp = self.budget_vs_actual()
if not budget_comp.empty:
story.append(Paragraph("Budget vs Actual", styles['Heading2']))
table_data = [['Category', 'Budget', 'Actual', 'Difference', 'Status']]
for _, row in budget_comp.iterrows():
status_color = 'green' if row['status'] == 'under' else 'orange' if row['status'] == 'warning' else 'red'
table_data.append([
row['category'],
f"${row['budget']:,.2f}",
f"${row['actual']:,.2f}",
f"${row['difference']:,.2f}",
row['status'].upper()
])
table = Table(table_data, colWidths=[1.5*inch, 1.2*inch, 1.2*inch, 1.2*inch, 1*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.steelblue),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('GRID', (0, 0), (-1, -1), 1, colors.black)
]))
story.append(table)
story.append(Spacer(1, 20))
# Recommendations
recommendations = self.get_recommendations()
if recommendations:
story.append(Paragraph("Recommendations", styles['Heading2']))
for rec in recommendations:
story.append(Paragraph(f"• {rec}", styles['Normal']))
story.append(Spacer(1, 10))
# Spending score
score = self.spending_score()
if score:
story.append(Paragraph("Spending Health Score", styles['Heading2']))
score_text = f"""
<b>Overall Score:</b> {score['overall_score']}/100 (Grade: {score['grade']})<br/>
<b>Summary:</b> {score['summary']}
"""
story.append(Paragraph(score_text, styles['Normal']))
doc.build(story)
return output
def _generate_html_report(self, output: str) -> str:
"""Generate HTML report."""
summary = self.analyze()
categories = self.by_category()
monthly = self.by_month()
recommendations = self.get_recommendations()
score = self.spending_score()
html = f"""<!DOCTYPE html>
<html>
<head>
<title>Budget Analysis Report</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }}
h1 {{ color: #2c3e50; }}
h2 {{ color: #34495e; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
table {{ width: 100%; border-collapse: collapse; margin: 20px 0; }}
th {{ background-color: #3498db; color: white; padding: 12px; text-align: left; }}
td {{ padding: 10px; border-bottom: 1px solid #ddd; }}
tr:hover {{ background-color: #f5f5f5; }}
.summary {{ background: #ecf0f1; padding: 20px; border-radius: 8px; }}
.score {{ font-size: 48px; font-weight: bold; color: #27ae60; }}
.recommendation {{ background: #fff3cd; padding: 10px; margin: 5px 0; border-left: 4px solid #ffc107; }}
.over {{ color: #e74c3c; }}
.under {{ color: #27ae60; }}
.warning {{ color: #f39c12; }}
</style>
</head>
<body>
<h1>Budget Analysis Report</h1>
<div class="summary">
<h2>Executive Summary</h2>
<p><strong>Total Spent:</strong> ${summary.get('total_spent', 0):,.2f}</p>
<p><strong>Transactions:</strong> {summary.get('transaction_count', 0)}</p>
<p><strong>Average Transaction:</strong> ${summary.get('average_transaction', 0):,.2f}</p>
<p><strong>Date Range:</strong> {summary.get('date_range', {}).get('start', 'N/A')} to {summary.get('date_range', {}).get('end', 'N/A')}</p>
</div>
<h2>Spending by Category</h2>
<table>
<tr><th>Category</th><th>Amount</th><th>Percentage</th><th>Transactions</th></tr>
"""
for _, row in categories.iterrows():
html += f"<tr><td>{row['category']}</td><td>${row['amount']:,.2f}</td><td>{row['percentage']:.1f}%</td><td>{int(row['count'])}</td></tr>\n"
html += """
</table>
<h2>Monthly Trends</h2>
<table>
<tr><th>Month</th><th>Total</th><th>Transactions</th><th>Average</th></tr>
"""
for _, row in monthly.iterrows():
html += f"<tr><td>{row['month']}</td><td>${row['total']:,.2f}</td><td>{int(row['count'])}</td><td>${row['avg_transaction']:,.2f}</td></tr>\n"
html += """
</table>
<h2>Recommendations</h2>
"""
for rec in recommendations:
html += f'<div class="recommendation">{rec}</div>\n'
html += f"""
<h2>Spending Health Score</h2>
<div class="score">{score.get('overall_score', 'N/A')}/100</div>
<p><strong>Grade:</strong> {score.get('grade', 'N/A')}</p>
<p>{score.get('summary', '')}</p>
</body>
</html>
"""
with open(output, 'w') as f:
f.write(html)
return output
def to_csv(self, output: str) -> str:
"""Export analyzed data to CSV."""
if self.df is not None:
self.df.to_csv(output, index=False)
return output
def main():
parser = argparse.ArgumentParser(description="Budget Analyzer - Analyze expenses and track budgets")
parser.add_argument("--input", "-i", required=True, help="Input CSV file")
parser.add_argument("--date", "-d", default="date", help="Date column name")
parser.add_argument("--amount", "-a", default="amount", help="Amount column name")
parser.add_argument("--description", default="description", help="Description column name")
parser.add_argument("--category-col", help="Category column name (if pre-categorized)")
parser.add_argument("--categories", help="JSON file with custom categories")
parser.add_argument("--budget", help="JSON file with budget targets")
parser.add_argument("--compare", nargs=2, metavar=('PERIOD1', 'PERIOD2'),
help="Compare two periods (e.g., 2024-01 2024-02)")
parser.add_argument("--report", "-r", help="Generate report (PDF or HTML)")
parser.add_argument("--plot-categories", help="Save category pie chart")
parser.add_argument("--plot-trends", help="Save trends line chart")
parser.add_argument("--plot-budget", help="Save budget comparison chart")
parser.add_argument("--output", "-o", help="Output CSV with categories")
args = parser.parse_args()
analyzer = BudgetAnalyzer()
# Load data
analyzer.load_csv(
args.input,
date_col=args.date,
amount_col=args.amount,
description_col=args.description,
category_col=args.category_col
)
# Load custom categories
if args.categories:
with open(args.categories) as f:
analyzer.set_categories(json.load(f))
# Auto-categorize if no category column
if not args.category_col:
analyzer.auto_categorize()
# Load budget
if args.budget:
with open(args.budget) as f:
analyzer.set_budget(json.load(f))
# Period comparison
if args.compare:
comparison = analyzer.compare_periods(args.compare[0], args.compare[1])
print(f"\n=== Period Comparison: {args.compare[0]} vs {args.compare[1]} ===")
print(f"Period 1 Total: ${comparison['period1_total']:,.2f}")
print(f"Period 2 Total: ${comparison['period2_total']:,.2f}")
print(f"Difference: ${comparison['difference']:,.2f} ({comparison['percent_change']:+.1f}%)")
else:
# Show summary
summary = analyzer.analyze()
print("\n=== Budget Analysis Summary ===")
print(f"Total Spent: ${summary['total_spent']:,.2f}")
print(f"Transactions: {summary['transaction_count']}")
print(f"Average: ${summary['average_transaction']:,.2f}")
print(f"Date Range: {summary['date_range']['start']} to {summary['date_range']['end']}")
print("\n=== Top Categories ===")
categories = analyzer.by_category()
for _, row in categories.head(5).iterrows():
print(f" {row['category']}: ${row['amount']:,.2f} ({row['percentage']:.1f}%)")
if analyzer.budget:
print("\n=== Budget Status ===")
budget_comp = analyzer.budget_vs_actual()
for _, row in budget_comp.iterrows():
status_icon = "✓" if row['status'] == 'under' else "⚠" if row['status'] == 'warning' else "✗"
print(f" {status_icon} {row['category']}: ${row['actual']:,.2f} / ${row['budget']:,.2f}")
print("\n=== Recommendations ===")
for rec in analyzer.get_recommendations():
print(f" • {rec}")
score = analyzer.spending_score()
print(f"\n=== Spending Score: {score['overall_score']}/100 (Grade: {score['grade']}) ===")
print(f" {score['summary']}")
# Generate visualizations
if args.plot_categories:
analyzer.plot_categories(args.plot_categories)
print(f"\nSaved category chart: {args.plot_categories}")
if args.plot_trends:
analyzer.plot_trends(args.plot_trends)
print(f"Saved trends chart: {args.plot_trends}")
if args.plot_budget and analyzer.budget:
analyzer.plot_budget_comparison(args.plot_budget)
print(f"Saved budget chart: {args.plot_budget}")
# Generate report
if args.report:
fmt = "html" if args.report.endswith(".html") else "pdf"
analyzer.generate_report(args.report, format=fmt)
print(f"\nGenerated report: {args.report}")
# Export
if args.output:
analyzer.to_csv(args.output)
print(f"Exported to: {args.output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Correlation Explorer - Find and visualize correlations in datasets.
Features:
- Correlation matrix computation
- Multiple methods (Pearson, Spearman, Kendall)
- Heatmap visualization
- P-value significance testing
- Strong/weak correlation discovery
"""
import argparse
import json
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
class CorrelationExplorer:
"""Analyze correlations between variables in datasets."""
def __init__(self):
"""Initialize explorer."""
self._pd = None
self._np = None
self._load_dependencies()
self.data = None
self._corr_matrix = None
def _load_dependencies(self):
"""Load required libraries."""
try:
import pandas as pd
import numpy as np
self._pd = pd
self._np = np
except ImportError:
raise ImportError("pandas and numpy required. Install with: pip install pandas numpy")
def load_csv(self, filepath: str, **kwargs) -> 'CorrelationExplorer':
"""
Load data from CSV file.
Args:
filepath: Path to CSV file
**kwargs: Additional pandas read_csv args
Returns:
self for chaining
"""
self.data = self._pd.read_csv(filepath, **kwargs)
# Select only numeric columns
self.data = self.data.select_dtypes(include=[self._np.number])
self._corr_matrix = None
return self
def load_dataframe(self, df) -> 'CorrelationExplorer':
"""
Load data from DataFrame.
Args:
df: pandas DataFrame
Returns:
self for chaining
"""
self.data = df.select_dtypes(include=[self._np.number])
self._corr_matrix = None
return self
def correlation_matrix(self, method: str = "pearson"):
"""
Compute correlation matrix.
Args:
method: Correlation method (pearson, spearman, kendall)
Returns:
Correlation DataFrame
"""
if self.data is None:
raise ValueError("No data loaded. Use load_csv() or load_dataframe() first.")
self._corr_matrix = self.data.corr(method=method)
return self._corr_matrix
def correlation_with_pvalues(self, method: str = "pearson") -> Tuple:
"""
Compute correlation matrix with p-values.
Args:
method: Correlation method
Returns:
(correlation_matrix, pvalue_matrix)
"""
from scipy import stats
if self.data is None:
raise ValueError("No data loaded.")
n = len(self.data.columns)
corr_matrix = self._pd.DataFrame(
self._np.zeros((n, n)),
columns=self.data.columns,
index=self.data.columns
)
pval_matrix = corr_matrix.copy()
for i, col1 in enumerate(self.data.columns):
for j, col2 in enumerate(self.data.columns):
# Remove NaN pairs
mask = ~(self.data[col1].isna() | self.data[col2].isna())
x = self.data[col1][mask]
y = self.data[col2][mask]
if method == 'pearson':
corr, pval = stats.pearsonr(x, y)
elif method == 'spearman':
corr, pval = stats.spearmanr(x, y)
elif method == 'kendall':
corr, pval = stats.kendalltau(x, y)
else:
raise ValueError(f"Unknown method: {method}")
corr_matrix.iloc[i, j] = corr
pval_matrix.iloc[i, j] = pval
self._corr_matrix = corr_matrix
return corr_matrix, pval_matrix
def correlate_with_target(self, target: str, method: str = "pearson"):
"""
Get correlations of all variables with a target variable.
Args:
target: Target column name
method: Correlation method
Returns:
Series of correlations sorted by absolute value
"""
if self.data is None:
raise ValueError("No data loaded.")
if target not in self.data.columns:
raise ValueError(f"Target '{target}' not found in data")
corr_matrix = self.correlation_matrix(method)
target_corr = corr_matrix[target].drop(target)
return target_corr.sort_values(key=abs, ascending=False)
def find_strong_correlations(
self,
threshold: float = 0.7,
method: str = "pearson"
) -> List[Dict]:
"""
Find pairs with strong correlations.
Args:
threshold: Minimum absolute correlation
method: Correlation method
Returns:
List of correlation pairs above threshold
"""
corr_matrix = self.correlation_matrix(method) if self._corr_matrix is None else self._corr_matrix
strong = []
for i in range(len(corr_matrix.columns)):
for j in range(i + 1, len(corr_matrix.columns)):
corr = corr_matrix.iloc[i, j]
if abs(corr) >= threshold:
strong.append({
'var1': corr_matrix.columns[i],
'var2': corr_matrix.columns[j],
'correlation': round(corr, 4),
'abs_corr': round(abs(corr), 4)
})
return sorted(strong, key=lambda x: x['abs_corr'], reverse=True)
def find_weak_correlations(
self,
threshold: float = 0.3,
method: str = "pearson"
) -> List[Dict]:
"""
Find pairs with weak correlations.
Args:
threshold: Maximum absolute correlation
method: Correlation method
Returns:
List of correlation pairs below threshold
"""
corr_matrix = self.correlation_matrix(method) if self._corr_matrix is None else self._corr_matrix
weak = []
for i in range(len(corr_matrix.columns)):
for j in range(i + 1, len(corr_matrix.columns)):
corr = corr_matrix.iloc[i, j]
if abs(corr) <= threshold:
weak.append({
'var1': corr_matrix.columns[i],
'var2': corr_matrix.columns[j],
'correlation': round(corr, 4),
'abs_corr': round(abs(corr), 4)
})
return sorted(weak, key=lambda x: x['abs_corr'])
def plot_heatmap(
self,
output: str,
method: str = "pearson",
cmap: str = "coolwarm",
annot: bool = True,
figsize: Tuple[int, int] = (10, 8),
title: str = "Correlation Matrix",
vmin: float = -1,
vmax: float = 1
) -> str:
"""
Generate correlation heatmap.
Args:
output: Output file path
method: Correlation method
cmap: Color map
annot: Show correlation values
figsize: Figure size
title: Plot title
vmin, vmax: Color scale limits
Returns:
Path to saved image
"""
try:
import matplotlib.pyplot as plt
import seaborn as sns
except ImportError:
raise ImportError("matplotlib and seaborn required for plotting")
corr_matrix = self.correlation_matrix(method) if self._corr_matrix is None else self._corr_matrix
fig, ax = plt.subplots(figsize=figsize)
sns.heatmap(
corr_matrix,
annot=annot,
cmap=cmap,
vmin=vmin,
vmax=vmax,
center=0,
square=True,
linewidths=0.5,
fmt='.2f',
ax=ax
)
ax.set_title(title)
plt.tight_layout()
plt.savefig(output, dpi=150)
plt.close()
return output
def plot_scatter(
self,
var1: str,
var2: str,
output: str,
figsize: Tuple[int, int] = (8, 6)
) -> str:
"""
Create scatter plot of two variables.
Args:
var1: First variable
var2: Second variable
output: Output file path
figsize: Figure size
Returns:
Path to saved image
"""
try:
import matplotlib.pyplot as plt
import seaborn as sns
except ImportError:
raise ImportError("matplotlib and seaborn required for plotting")
if self.data is None:
raise ValueError("No data loaded.")
fig, ax = plt.subplots(figsize=figsize)
sns.scatterplot(data=self.data, x=var1, y=var2, ax=ax)
# Add trend line
from scipy import stats
mask = ~(self.data[var1].isna() | self.data[var2].isna())
x = self.data[var1][mask]
y = self.data[var2][mask]
slope, intercept, r, p, se = stats.linregress(x, y)
ax.plot(x, slope * x + intercept, 'r--', label=f'r={r:.3f}')
ax.legend()
ax.set_title(f'{var1} vs {var2}')
plt.tight_layout()
plt.savefig(output, dpi=150)
plt.close()
return output
def to_csv(self, output: str, method: str = "pearson") -> str:
"""Save correlation matrix to CSV."""
corr_matrix = self.correlation_matrix(method) if self._corr_matrix is None else self._corr_matrix
corr_matrix.to_csv(output)
return output
def to_json(self, output: str, method: str = "pearson") -> str:
"""Save correlation matrix to JSON."""
corr_matrix = self.correlation_matrix(method) if self._corr_matrix is None else self._corr_matrix
corr_matrix.to_json(output, orient='index', indent=2)
return output
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(description='Explore correlations in data')
parser.add_argument('--input', '-i', required=True, help='Input CSV file')
parser.add_argument('--output', '-o', help='Output file for correlation matrix')
parser.add_argument('--heatmap', help='Generate heatmap image')
parser.add_argument('--method', '-m', default='pearson',
choices=['pearson', 'spearman', 'kendall'])
parser.add_argument('--target', '-t', help='Show correlations with target variable')
parser.add_argument('--strong', action='store_true', help='Find strong correlations')
parser.add_argument('--weak', action='store_true', help='Find weak correlations')
parser.add_argument('--threshold', type=float, default=0.7,
help='Threshold for strong/weak (default: 0.7)')
parser.add_argument('--pvalues', action='store_true', help='Include p-values')
parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
explorer = CorrelationExplorer()
explorer.load_csv(args.input)
if args.heatmap:
explorer.plot_heatmap(args.heatmap, method=args.method)
print(f"Heatmap saved to: {args.heatmap}")
elif args.target:
corr = explorer.correlate_with_target(args.target, method=args.method)
print(f"\nCorrelations with '{args.target}':")
print("-" * 40)
for var, value in corr.items():
print(f" {var}: {value:.4f}")
elif args.strong:
strong = explorer.find_strong_correlations(args.threshold, method=args.method)
print(f"\nStrong correlations (|r| >= {args.threshold}):")
print("-" * 50)
if strong:
for pair in strong:
print(f" {pair['var1']} <-> {pair['var2']}: {pair['correlation']:.4f}")
else:
print(" No strong correlations found")
elif args.weak:
weak = explorer.find_weak_correlations(args.threshold, method=args.method)
print(f"\nWeak correlations (|r| <= {args.threshold}):")
print("-" * 50)
for pair in weak[:20]: # Show first 20
print(f" {pair['var1']} <-> {pair['var2']}: {pair['correlation']:.4f}")
elif args.pvalues:
corr, pvals = explorer.correlation_with_pvalues(method=args.method)
print("\nCorrelation Matrix:")
print(corr.round(4))
print("\nP-Values:")
print(pvals.round(4))
else:
corr_matrix = explorer.correlation_matrix(method=args.method)
if args.output:
if args.json:
explorer.to_json(args.output)
else:
explorer.to_csv(args.output)
print(f"Correlation matrix saved to: {args.output}")
else:
print("\nCorrelation Matrix:")
print(corr_matrix.round(4))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Data Type Converter - Convert between JSON, CSV, XML, YAML, TOML.
Features:
- Multi-format conversion
- Nested data handling
- Batch processing
- Pretty output
"""
import argparse
import json
import csv
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
class DataTypeConverter:
"""Convert between data formats."""
SUPPORTED_FORMATS = {'json', 'csv', 'xml', 'yaml', 'toml'}
def __init__(self):
"""Initialize converter."""
pass
def _detect_format(self, filepath: str) -> str:
"""Detect format from file extension."""
ext = Path(filepath).suffix.lower().lstrip('.')
if ext in self.SUPPORTED_FORMATS:
return ext
if ext == 'yml':
return 'yaml'
raise ValueError(f"Unknown format: {ext}")
def load(self, filepath: str, format: Optional[str] = None) -> Any:
"""
Load data from file.
Args:
filepath: Path to file
format: Format (auto-detect if None)
Returns:
Loaded data
"""
format = format or self._detect_format(filepath)
text = Path(filepath).read_text()
if format == 'json':
return json.loads(text)
elif format == 'csv':
import io
reader = csv.DictReader(io.StringIO(text))
return list(reader)
elif format == 'yaml':
try:
import yaml
return yaml.safe_load(text)
except ImportError:
raise ImportError("PyYAML required. Install with: pip install pyyaml")
elif format == 'toml':
try:
import toml
return toml.loads(text)
except ImportError:
raise ImportError("toml required. Install with: pip install toml")
elif format == 'xml':
try:
import xmltodict
return xmltodict.parse(text)
except ImportError:
raise ImportError("xmltodict required. Install with: pip install xmltodict")
raise ValueError(f"Unsupported format: {format}")
def save(
self,
data: Any,
filepath: str,
format: Optional[str] = None,
indent: int = 2,
root: str = "root",
flatten: bool = False
) -> str:
"""
Save data to file.
Args:
data: Data to save
filepath: Output path
format: Format (auto-detect if None)
indent: Indentation for pretty output
root: Root element name for XML
flatten: Flatten nested data for CSV
Returns:
Output path
"""
format = format or self._detect_format(filepath)
if format == 'json':
output = json.dumps(data, indent=indent, default=str)
elif format == 'csv':
output = self._to_csv(data, flatten=flatten)
elif format == 'yaml':
try:
import yaml
output = yaml.dump(data, default_flow_style=False, sort_keys=False)
except ImportError:
raise ImportError("PyYAML required")
elif format == 'toml':
try:
import toml
output = toml.dumps(data)
except ImportError:
raise ImportError("toml required")
elif format == 'xml':
try:
import xmltodict
if not isinstance(data, dict):
data = {root: data}
elif len(data) != 1:
data = {root: data}
output = xmltodict.unparse(data, pretty=True)
except ImportError:
raise ImportError("xmltodict required")
else:
raise ValueError(f"Unsupported format: {format}")
Path(filepath).write_text(output)
return filepath
def _flatten_dict(self, d: Dict, parent_key: str = '', sep: str = '.') -> Dict:
"""Flatten nested dictionary."""
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}[{i}]", sep=sep).items())
else:
items.append((f"{new_key}[{i}]", item))
else:
items.append((new_key, v))
return dict(items)
def _to_csv(self, data: Any, flatten: bool = False) -> str:
"""Convert data to CSV string."""
import io
# Ensure data is a list
if isinstance(data, dict):
if flatten:
data = [self._flatten_dict(data)]
else:
data = [data]
elif not isinstance(data, list):
data = [data]
# Flatten if requested
if flatten and data:
data = [self._flatten_dict(d) if isinstance(d, dict) else d for d in data]
# Get all keys
if data and isinstance(data[0], dict):
keys = []
for row in data:
for k in row.keys():
if k not in keys:
keys.append(k)
else:
return str(data)
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=keys)
writer.writeheader()
for row in data:
writer.writerow(row)
return output.getvalue()
def convert(
self,
input_path: str,
output_path: str,
indent: int = 2,
root: str = "root",
flatten: bool = False
) -> str:
"""
Convert file from one format to another.
Args:
input_path: Input file path
output_path: Output file path
indent: Indentation for output
root: XML root element
flatten: Flatten nested data for CSV
Returns:
Output path
"""
data = self.load(input_path)
return self.save(data, output_path, indent=indent, root=root, flatten=flatten)
def convert_string(
self,
data_str: str,
from_format: str,
to_format: str,
indent: int = 2
) -> str:
"""
Convert data string between formats.
Args:
data_str: Input data string
from_format: Source format
to_format: Target format
indent: Indentation
Returns:
Converted string
"""
# Parse input
if from_format == 'json':
data = json.loads(data_str)
elif from_format == 'yaml':
import yaml
data = yaml.safe_load(data_str)
elif from_format == 'toml':
import toml
data = toml.loads(data_str)
elif from_format == 'xml':
import xmltodict
data = xmltodict.parse(data_str)
else:
raise ValueError(f"Unsupported source format: {from_format}")
# Generate output
if to_format == 'json':
return json.dumps(data, indent=indent, default=str)
elif to_format == 'yaml':
import yaml
return yaml.dump(data, default_flow_style=False)
elif to_format == 'toml':
import toml
return toml.dumps(data)
elif to_format == 'xml':
import xmltodict
if not isinstance(data, dict) or len(data) != 1:
data = {'root': data}
return xmltodict.unparse(data, pretty=True)
else:
raise ValueError(f"Unsupported target format: {to_format}")
def batch_convert(
self,
input_dir: str,
output_dir: str,
output_format: str,
flatten: bool = False
) -> List[str]:
"""
Batch convert all files in directory.
Args:
input_dir: Input directory
output_dir: Output directory
output_format: Target format
flatten: Flatten nested data
Returns:
List of output paths
"""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
converted = []
for ext in self.SUPPORTED_FORMATS:
for filepath in input_path.glob(f'*.{ext}'):
try:
output_file = output_path / f"{filepath.stem}.{output_format}"
self.convert(str(filepath), str(output_file), flatten=flatten)
converted.append(str(output_file))
print(f"Converted: {filepath.name} -> {output_file.name}")
except Exception as e:
print(f"Failed: {filepath.name} - {e}")
return converted
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(description='Convert between data formats')
parser.add_argument('--input', '-i', help='Input file')
parser.add_argument('--output', '-o', help='Output file')
parser.add_argument('--input-dir', help='Input directory for batch')
parser.add_argument('--output-dir', help='Output directory for batch')
parser.add_argument('--format', '-f', help='Output format')
parser.add_argument('--flatten', action='store_true', help='Flatten nested data')
parser.add_argument('--indent', type=int, default=2, help='Indentation')
parser.add_argument('--root', default='root', help='XML root element')
args = parser.parse_args()
converter = DataTypeConverter()
if args.input_dir and args.output_dir:
if not args.format:
parser.error("--format required for batch conversion")
converted = converter.batch_convert(
args.input_dir,
args.output_dir,
args.format,
flatten=args.flatten
)
print(f"\nConverted {len(converted)} files")
elif args.input and args.output:
converter.convert(
args.input,
args.output,
indent=args.indent,
root=args.root,
flatten=args.flatten
)
print(f"Converted: {args.input} -> {args.output}")
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Data Quality Auditor - Comprehensive data quality assessment.
Features:
- Missing values analysis
- Duplicate detection
- Type validation
- Pattern checking
- Quality scoring
- Report generation
"""
import argparse
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
class DataQualityAuditor:
"""Assess data quality in datasets."""
def __init__(self):
"""Initialize auditor."""
self._pd = None
self._np = None
self._load_dependencies()
self.data = None
self._audit_cache = None
def _load_dependencies(self):
"""Load required libraries."""
try:
import pandas as pd
import numpy as np
self._pd = pd
self._np = np
except ImportError:
raise ImportError("pandas and numpy required. Install with: pip install pandas numpy")
def load_csv(self, filepath: str, **kwargs) -> 'DataQualityAuditor':
"""
Load data from CSV.
Args:
filepath: Path to CSV file
**kwargs: pandas read_csv arguments
Returns:
self for chaining
"""
self.data = self._pd.read_csv(filepath, **kwargs)
self._filepath = filepath
self._audit_cache = None
return self
def load_dataframe(self, df) -> 'DataQualityAuditor':
"""
Load data from DataFrame.
Args:
df: pandas DataFrame
Returns:
self for chaining
"""
self.data = df.copy()
self._filepath = "DataFrame"
self._audit_cache = None
return self
def audit(self) -> Dict:
"""
Run full data quality audit.
Returns:
Comprehensive audit report
"""
if self.data is None:
raise ValueError("No data loaded.")
# Run all checks
missing = self.check_missing()
duplicates = self.check_duplicates()
types = self.check_types()
uniqueness = self.check_uniqueness()
# Calculate scores
completeness_score = 100 - missing['missing_percent']
uniqueness_score = 100 - duplicates['duplicate_percent']
validity_score = self._calculate_validity_score(types)
consistency_score = self._calculate_consistency_score()
# Overall quality score (weighted average)
quality_score = (
completeness_score * 0.30 +
uniqueness_score * 0.25 +
validity_score * 0.25 +
consistency_score * 0.20
)
# Generate recommendations
recommendations = self._generate_recommendations(
missing, duplicates, types
)
self._audit_cache = {
'file': getattr(self, '_filepath', 'Unknown'),
'rows': len(self.data),
'columns': len(self.data.columns),
'quality_score': round(quality_score, 1),
'completeness': {
'score': round(completeness_score, 1),
'missing_cells': missing['missing_cells'],
'details': missing
},
'uniqueness': {
'score': round(uniqueness_score, 1),
'duplicate_rows': duplicates['duplicate_rows'],
'details': duplicates
},
'validity': {
'score': round(validity_score, 1),
'details': types
},
'consistency': {
'score': round(consistency_score, 1),
'details': uniqueness
},
'recommendations': recommendations
}
return self._audit_cache
def quality_score(self) -> float:
"""Get overall quality score."""
if self._audit_cache is None:
self.audit()
return self._audit_cache['quality_score']
def check_missing(self) -> Dict:
"""
Analyze missing values.
Returns:
Missing values report
"""
if self.data is None:
raise ValueError("No data loaded.")
total_cells = self.data.size
missing_cells = self.data.isna().sum().sum()
missing_percent = 100 * missing_cells / total_cells if total_cells > 0 else 0
by_column = {}
for col in self.data.columns:
col_missing = self.data[col].isna().sum()
col_total = len(self.data)
by_column[col] = {
'count': int(col_missing),
'percent': round(100 * col_missing / col_total, 2) if col_total > 0 else 0
}
rows_with_missing = self.data.isna().any(axis=1).sum()
return {
'total_cells': int(total_cells),
'missing_cells': int(missing_cells),
'missing_percent': round(missing_percent, 2),
'by_column': by_column,
'rows_with_missing': int(rows_with_missing)
}
def check_duplicates(self, subset: Optional[List[str]] = None) -> Dict:
"""
Detect duplicate rows.
Args:
subset: Columns to check for duplicates
Returns:
Duplicate analysis report
"""
if self.data is None:
raise ValueError("No data loaded.")
total_rows = len(self.data)
duplicates = self.data.duplicated(subset=subset, keep='first')
duplicate_rows = duplicates.sum()
duplicate_percent = 100 * duplicate_rows / total_rows if total_rows > 0 else 0
# Find duplicate groups
duplicate_groups = []
if duplicate_rows > 0:
dup_mask = self.data.duplicated(subset=subset, keep=False)
dup_df = self.data[dup_mask]
if subset:
groups = dup_df.groupby(subset).size()
else:
groups = dup_df.groupby(list(self.data.columns)).size()
duplicate_groups = groups[groups > 1].head(10).to_dict()
# Check duplicates by individual columns
by_columns = {}
for col in self.data.columns:
col_dups = self.data[col].duplicated(keep='first').sum()
if col_dups > 0:
by_columns[col] = {'duplicates': int(col_dups)}
return {
'total_rows': total_rows,
'duplicate_rows': int(duplicate_rows),
'duplicate_percent': round(duplicate_percent, 2),
'duplicate_groups': duplicate_groups,
'by_columns': by_columns
}
def check_types(self) -> Dict:
"""
Analyze column types.
Returns:
Type analysis report
"""
if self.data is None:
raise ValueError("No data loaded.")
columns = {}
for col in self.data.columns:
series = self.data[col]
detected_type = str(series.dtype)
unique_values = series.nunique()
sample_values = series.dropna().head(5).tolist()
issues = []
# Check for mixed types in object columns
if series.dtype == 'object':
type_counts = series.apply(type).value_counts()
if len(type_counts) > 1:
issues.append("Mixed types detected")
# Check for potential numeric columns stored as strings
try:
numeric_count = self._pd.to_numeric(series, errors='coerce').notna().sum()
if numeric_count > len(series) * 0.8:
issues.append("Potential numeric column stored as string")
except:
pass
# Check for potential date columns
try:
date_count = self._pd.to_datetime(series, errors='coerce').notna().sum()
if date_count > len(series) * 0.8:
issues.append("Potential date column stored as string")
except:
pass
# Check for outliers in numeric columns
if self._np.issubdtype(series.dtype, self._np.number):
if series.std() > 0:
z_scores = (series - series.mean()) / series.std()
outlier_count = (abs(z_scores) > 3).sum()
if outlier_count > 0:
issues.append(f"{outlier_count} potential outliers (|z| > 3)")
columns[col] = {
'detected_type': detected_type,
'unique_values': int(unique_values),
'sample_values': sample_values,
'issues': issues
}
return {'columns': columns}
def check_uniqueness(self) -> Dict:
"""
Check column uniqueness (cardinality).
Returns:
Uniqueness analysis
"""
if self.data is None:
raise ValueError("No data loaded.")
total_rows = len(self.data)
columns = {}
for col in self.data.columns:
unique = self.data[col].nunique()
uniqueness_ratio = unique / total_rows if total_rows > 0 else 0
columns[col] = {
'unique_values': int(unique),
'uniqueness_ratio': round(uniqueness_ratio, 4),
'is_unique': unique == total_rows,
'is_constant': unique == 1
}
# Identify potential ID columns (high uniqueness)
potential_ids = [
col for col, info in columns.items()
if info['uniqueness_ratio'] > 0.95
]
# Identify low cardinality columns (potential categoricals)
potential_categoricals = [
col for col, info in columns.items()
if info['unique_values'] < 20 and info['uniqueness_ratio'] < 0.1
]
return {
'columns': columns,
'potential_ids': potential_ids,
'potential_categoricals': potential_categoricals
}
def check_patterns(self, column: str, pattern: str) -> Dict:
"""
Check column values against regex pattern.
Args:
column: Column name
pattern: Regex pattern
Returns:
Pattern match report
"""
if self.data is None:
raise ValueError("No data loaded.")
series = self.data[column].astype(str)
regex = re.compile(pattern)
matches = series.apply(lambda x: bool(regex.match(str(x))))
match_count = matches.sum()
non_match_count = (~matches).sum()
non_matching_samples = series[~matches].head(10).tolist()
return {
'column': column,
'pattern': pattern,
'total': len(series),
'matches': int(match_count),
'non_matches': int(non_match_count),
'match_percent': round(100 * match_count / len(series), 2),
'non_matching_samples': non_matching_samples
}
def validate_column(self, column: str, rules: Dict) -> Dict:
"""
Validate column against rules.
Args:
column: Column name
rules: Validation rules
Returns:
Validation result
"""
if self.data is None:
raise ValueError("No data loaded.")
if column not in self.data.columns:
return {'valid': False, 'issues': [f"Column '{column}' not found"]}
series = self.data[column]
issues = []
# Required check
if rules.get('required'):
missing = series.isna().sum()
if missing > 0:
issues.append(f"{missing} missing values")
# Unique check
if rules.get('unique'):
dups = series.duplicated().sum()
if dups > 0:
issues.append(f"{dups} duplicate values")
# Pattern check
if 'pattern' in rules:
pattern_result = self.check_patterns(column, rules['pattern'])
if pattern_result['non_matches'] > 0:
issues.append(f"{pattern_result['non_matches']} values don't match pattern")
# Type check
if 'type' in rules:
expected_type = rules['type']
if expected_type == 'integer':
non_int = series.dropna().apply(lambda x: not isinstance(x, (int, self._np.integer)))
if non_int.sum() > 0:
issues.append(f"{non_int.sum()} non-integer values")
elif expected_type == 'date':
try:
self._pd.to_datetime(series, format=rules.get('format'))
except:
issues.append("Date parsing failed")
# Range checks
if 'min' in rules:
below_min = (series < rules['min']).sum()
if below_min > 0:
issues.append(f"{below_min} values below minimum {rules['min']}")
if 'max' in rules:
above_max = (series > rules['max']).sum()
if above_max > 0:
issues.append(f"{above_max} values above maximum {rules['max']}")
# Allowed values
if 'allowed_values' in rules:
invalid = ~series.isin(rules['allowed_values'])
if invalid.sum() > 0:
issues.append(f"{invalid.sum()} values not in allowed list")
return {
'column': column,
'valid': len(issues) == 0,
'issues': issues
}
def validate_dataset(self, rules: Dict) -> Dict:
"""
Validate entire dataset against rules.
Args:
rules: Dict with column rules
Returns:
Validation results
"""
results = {}
all_valid = True
for column, col_rules in rules.get('columns', {}).items():
result = self.validate_column(column, col_rules)
results[column] = result
if not result['valid']:
all_valid = False
return {
'valid': all_valid,
'columns': results
}
def _calculate_validity_score(self, types: Dict) -> float:
"""Calculate validity score from type analysis."""
if not types.get('columns'):
return 100.0
total_issues = sum(
len(col_info['issues'])
for col_info in types['columns'].values()
)
max_issues = len(types['columns']) * 3 # Assume max 3 issues per column
return max(0, 100 - (100 * total_issues / max_issues if max_issues > 0 else 0))
def _calculate_consistency_score(self) -> float:
"""Calculate consistency score."""
uniqueness = self.check_uniqueness()
total_cols = len(uniqueness['columns'])
if total_cols == 0:
return 100.0
# Penalize constant columns
constant_cols = sum(1 for c in uniqueness['columns'].values() if c['is_constant'])
penalty = (constant_cols / total_cols) * 20
return max(0, 100 - penalty)
def _generate_recommendations(
self,
missing: Dict,
duplicates: Dict,
types: Dict
) -> List[str]:
"""Generate recommendations based on audit results."""
recommendations = []
# Missing value recommendations
for col, info in missing['by_column'].items():
if info['percent'] >= 5:
recommendations.append(
f"Column '{col}' has {info['percent']}% missing values"
)
# Duplicate recommendations
if duplicates['duplicate_rows'] > 0:
recommendations.append(
f"{duplicates['duplicate_rows']} duplicate rows detected"
)
# Type recommendations
for col, info in types['columns'].items():
for issue in info['issues']:
recommendations.append(f"Column '{col}': {issue}")
return recommendations[:10] # Limit to top 10
def generate_report(self, output: str, format: str = "html") -> str:
"""
Generate quality report file.
Args:
output: Output file path
format: Report format (html, json)
Returns:
Path to generated report
"""
if self._audit_cache is None:
self.audit()
if format == 'json':
with open(output, 'w') as f:
json.dump(self._audit_cache, f, indent=2, default=str)
elif format == 'html':
html = self._generate_html_report()
with open(output, 'w') as f:
f.write(html)
else:
raise ValueError(f"Unknown format: {format}")
return output
def _generate_html_report(self) -> str:
"""Generate HTML report."""
report = self._audit_cache
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Data Quality Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
h1 {{ color: #333; }}
.score {{ font-size: 48px; font-weight: bold; }}
.score.good {{ color: #28a745; }}
.score.warning {{ color: #ffc107; }}
.score.bad {{ color: #dc3545; }}
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #4CAF50; color: white; }}
.recommendation {{ background-color: #fff3cd; padding: 10px; margin: 5px 0; }}
</style>
</head>
<body>
<h1>Data Quality Report</h1>
<p>File: {report['file']}</p>
<p>Rows: {report['rows']:,} | Columns: {report['columns']}</p>
<h2>Overall Quality Score</h2>
<div class="score {'good' if report['quality_score'] >= 80 else 'warning' if report['quality_score'] >= 60 else 'bad'}">
{report['quality_score']}/100
</div>
<h2>Component Scores</h2>
<table>
<tr><th>Component</th><th>Score</th><th>Details</th></tr>
<tr><td>Completeness</td><td>{report['completeness']['score']}</td>
<td>{report['completeness']['missing_cells']} missing cells</td></tr>
<tr><td>Uniqueness</td><td>{report['uniqueness']['score']}</td>
<td>{report['uniqueness']['duplicate_rows']} duplicate rows</td></tr>
<tr><td>Validity</td><td>{report['validity']['score']}</td><td>Type issues checked</td></tr>
<tr><td>Consistency</td><td>{report['consistency']['score']}</td><td>Pattern consistency</td></tr>
</table>
<h2>Recommendations</h2>
{''.join(f'<div class="recommendation">{r}</div>' for r in report['recommendations']) or '<p>No issues found</p>'}
</body>
</html>
"""
return html
def summary(self) -> str:
"""Get text summary of audit."""
if self._audit_cache is None:
self.audit()
report = self._audit_cache
lines = [
f"Data Quality Report: {report['file']}",
f"{'=' * 50}",
f"Rows: {report['rows']:,} | Columns: {report['columns']}",
f"",
f"Quality Score: {report['quality_score']}/100",
f" - Completeness: {report['completeness']['score']}/100",
f" - Uniqueness: {report['uniqueness']['score']}/100",
f" - Validity: {report['validity']['score']}/100",
f" - Consistency: {report['consistency']['score']}/100",
f"",
f"Issues:",
]
for rec in report['recommendations']:
lines.append(f" - {rec}")
if not report['recommendations']:
lines.append(" No issues found")
return '\n'.join(lines)
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(description='Audit data quality')
parser.add_argument('--input', '-i', required=True, help='Input CSV file')
parser.add_argument('--report', '-r', help='Generate report file')
parser.add_argument('--format', '-f', default='html', choices=['html', 'json'])
parser.add_argument('--missing', action='store_true', help='Check missing values')
parser.add_argument('--duplicates', action='store_true', help='Check duplicates')
parser.add_argument('--types', action='store_true', help='Check types')
parser.add_argument('--rules', help='Validation rules JSON file')
parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
auditor = DataQualityAuditor()
auditor.load_csv(args.input)
if args.missing:
result = auditor.check_missing()
if args.json:
print(json.dumps(result, indent=2))
else:
print("Missing Values Analysis")
print("=" * 40)
print(f"Total cells: {result['total_cells']:,}")
print(f"Missing cells: {result['missing_cells']:,} ({result['missing_percent']}%)")
print(f"Rows with missing: {result['rows_with_missing']:,}")
print("\nBy Column:")
for col, info in result['by_column'].items():
if info['count'] > 0:
print(f" {col}: {info['count']} ({info['percent']}%)")
elif args.duplicates:
result = auditor.check_duplicates()
if args.json:
print(json.dumps(result, indent=2))
else:
print("Duplicate Analysis")
print("=" * 40)
print(f"Total rows: {result['total_rows']:,}")
print(f"Duplicate rows: {result['duplicate_rows']:,} ({result['duplicate_percent']}%)")
elif args.types:
result = auditor.check_types()
if args.json:
print(json.dumps(result, indent=2))
else:
print("Type Analysis")
print("=" * 40)
for col, info in result['columns'].items():
print(f"\n{col}:")
print(f" Type: {info['detected_type']}")
print(f" Unique values: {info['unique_values']}")
if info['issues']:
print(f" Issues: {', '.join(info['issues'])}")
elif args.rules:
with open(args.rules) as f:
rules = json.load(f)
result = auditor.validate_dataset(rules)
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Validation: {'PASSED' if result['valid'] else 'FAILED'}")
for col, info in result['columns'].items():
status = 'OK' if info['valid'] else 'FAIL'
print(f" {col}: {status}")
for issue in info['issues']:
print(f" - {issue}")
elif args.report:
auditor.generate_report(args.report, format=args.format)
print(f"Report saved to: {args.report}")
else:
# Full audit
report = auditor.audit()
if args.json:
print(json.dumps(report, indent=2, default=str))
else:
print(auditor.summary())
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Dataset Comparer - Compare two datasets to find differences.
"""
import argparse
import json
from typing import Dict, List, Optional, Any
from datetime import datetime
import html
import numpy as np
import pandas as pd
class DatasetComparer:
"""Compare two datasets to identify differences."""
def __init__(self):
"""Initialize the comparer."""
self.old_df: Optional[pd.DataFrame] = None
self.new_df: Optional[pd.DataFrame] = None
self.comparison_result: Optional[Dict] = None
self.key_columns: List[str] = []
self._added: Optional[pd.DataFrame] = None
self._removed: Optional[pd.DataFrame] = None
self._changed: Optional[pd.DataFrame] = None
self._unchanged: Optional[pd.DataFrame] = None
def load(self, old_path: str, new_path: str) -> 'DatasetComparer':
"""
Load datasets from files.
Args:
old_path: Path to old/baseline dataset
new_path: Path to new dataset
Returns:
Self for method chaining
"""
# Detect file type and load
self.old_df = self._load_file(old_path)
self.new_df = self._load_file(new_path)
return self
def _load_file(self, filepath: str) -> pd.DataFrame:
"""Load file based on extension."""
if filepath.endswith('.csv'):
return pd.read_csv(filepath)
elif filepath.endswith(('.xlsx', '.xls')):
return pd.read_excel(filepath)
elif filepath.endswith('.json'):
return pd.read_json(filepath)
else:
# Try CSV as default
return pd.read_csv(filepath)
def load_dataframes(self, old_df: pd.DataFrame,
new_df: pd.DataFrame) -> 'DatasetComparer':
"""
Load from pandas DataFrames.
Args:
old_df: Old/baseline DataFrame
new_df: New DataFrame
Returns:
Self for method chaining
"""
self.old_df = old_df.copy()
self.new_df = new_df.copy()
return self
def compare_schema(self) -> Dict:
"""
Compare column structure between datasets.
Returns:
Dictionary with schema comparison
"""
if self.old_df is None or self.new_df is None:
raise ValueError("No data loaded")
old_cols = set(self.old_df.columns)
new_cols = set(self.new_df.columns)
common = old_cols & new_cols
added = new_cols - old_cols
removed = old_cols - new_cols
# Check type changes in common columns
type_changes = []
for col in common:
old_type = str(self.old_df[col].dtype)
new_type = str(self.new_df[col].dtype)
if old_type != new_type:
type_changes.append({
"column": col,
"old_type": old_type,
"new_type": new_type
})
return {
"old_columns": list(self.old_df.columns),
"new_columns": list(self.new_df.columns),
"common_columns": list(common),
"added_columns": list(added),
"removed_columns": list(removed),
"type_changes": type_changes,
"old_row_count": len(self.old_df),
"new_row_count": len(self.new_df)
}
def compare(self, key_columns: List[str] = None,
ignore_columns: List[str] = None,
compare_columns: List[str] = None) -> Dict:
"""
Compare datasets and identify differences.
Args:
key_columns: Columns to use as row identifiers
ignore_columns: Columns to exclude from comparison
compare_columns: Only compare these columns (if specified)
Returns:
Dictionary with comparison results
"""
if self.old_df is None or self.new_df is None:
raise ValueError("No data loaded")
self.key_columns = key_columns or []
ignore_columns = ignore_columns or []
# Get common columns
common_cols = set(self.old_df.columns) & set(self.new_df.columns)
# Determine columns to compare
if compare_columns:
cols_to_compare = [c for c in compare_columns if c in common_cols]
else:
cols_to_compare = [c for c in common_cols
if c not in ignore_columns and c not in self.key_columns]
# Schema comparison
schema_changes = self.compare_schema()
if key_columns:
self._compare_by_key(key_columns, cols_to_compare)
else:
self._compare_by_position(cols_to_compare)
self.comparison_result = {
"summary": {
"old_rows": len(self.old_df),
"new_rows": len(self.new_df),
"added_count": len(self._added),
"removed_count": len(self._removed),
"changed_count": len(self._changed),
"unchanged_count": len(self._unchanged) if self._unchanged is not None else 0,
"total_differences": len(self._added) + len(self._removed) + len(self._changed)
},
"schema_changes": {
"added_columns": schema_changes["added_columns"],
"removed_columns": schema_changes["removed_columns"],
"type_changes": schema_changes["type_changes"]
},
"key_columns": self.key_columns,
"compared_columns": cols_to_compare,
"ignored_columns": ignore_columns
}
return self.comparison_result
def _compare_by_key(self, key_columns: List[str], compare_cols: List[str]):
"""Compare datasets by key columns."""
# Create key column
old_df = self.old_df.copy()
new_df = self.new_df.copy()
# Create composite key
old_df['_key'] = old_df[key_columns].astype(str).agg('|'.join, axis=1)
new_df['_key'] = new_df[key_columns].astype(str).agg('|'.join, axis=1)
old_keys = set(old_df['_key'])
new_keys = set(new_df['_key'])
# Find added/removed
added_keys = new_keys - old_keys
removed_keys = old_keys - new_keys
common_keys = old_keys & new_keys
self._added = new_df[new_df['_key'].isin(added_keys)].drop('_key', axis=1)
self._removed = old_df[old_df['_key'].isin(removed_keys)].drop('_key', axis=1)
# Compare common rows
old_common = old_df[old_df['_key'].isin(common_keys)].set_index('_key')
new_common = new_df[new_df['_key'].isin(common_keys)].set_index('_key')
# Find changed rows
changed_rows = []
unchanged_indices = []
for key in common_keys:
old_row = old_common.loc[key]
new_row = new_common.loc[key]
changes_in_row = []
for col in compare_cols:
if col in old_row.index and col in new_row.index:
old_val = old_row[col]
new_val = new_row[col]
# Handle NaN comparisons
if pd.isna(old_val) and pd.isna(new_val):
continue
elif pd.isna(old_val) or pd.isna(new_val) or old_val != new_val:
changes_in_row.append({
"_key": key,
"_column": col,
"_old_value": old_val,
"_new_value": new_val
})
if changes_in_row:
changed_rows.extend(changes_in_row)
else:
unchanged_indices.append(key)
self._changed = pd.DataFrame(changed_rows) if changed_rows else pd.DataFrame()
self._unchanged = old_common.loc[old_common.index.isin(unchanged_indices)].reset_index(drop=True)
def _compare_by_position(self, compare_cols: List[str]):
"""Compare datasets by row position."""
min_rows = min(len(self.old_df), len(self.new_df))
# Added = new rows beyond old count
if len(self.new_df) > len(self.old_df):
self._added = self.new_df.iloc[len(self.old_df):].copy()
else:
self._added = pd.DataFrame()
# Removed = old rows beyond new count
if len(self.old_df) > len(self.new_df):
self._removed = self.old_df.iloc[len(self.new_df):].copy()
else:
self._removed = pd.DataFrame()
# Compare overlapping rows
changed_rows = []
unchanged_indices = []
for i in range(min_rows):
old_row = self.old_df.iloc[i]
new_row = self.new_df.iloc[i]
changes_in_row = []
for col in compare_cols:
if col in old_row.index and col in new_row.index:
old_val = old_row[col]
new_val = new_row[col]
if pd.isna(old_val) and pd.isna(new_val):
continue
elif pd.isna(old_val) or pd.isna(new_val) or old_val != new_val:
changes_in_row.append({
"_key": i,
"_column": col,
"_old_value": old_val,
"_new_value": new_val
})
if changes_in_row:
changed_rows.extend(changes_in_row)
else:
unchanged_indices.append(i)
self._changed = pd.DataFrame(changed_rows) if changed_rows else pd.DataFrame()
self._unchanged = self.old_df.iloc[unchanged_indices].copy() if unchanged_indices else pd.DataFrame()
def get_added_rows(self) -> pd.DataFrame:
"""Get rows that were added in the new dataset."""
if self._added is None:
raise ValueError("Run compare() first")
return self._added.copy()
def get_removed_rows(self) -> pd.DataFrame:
"""Get rows that were removed from the old dataset."""
if self._removed is None:
raise ValueError("Run compare() first")
return self._removed.copy()
def get_changed_rows(self) -> pd.DataFrame:
"""Get details of changed values."""
if self._changed is None:
raise ValueError("Run compare() first")
return self._changed.copy()
def get_unchanged_rows(self) -> pd.DataFrame:
"""Get rows that remained unchanged."""
if self._unchanged is None:
raise ValueError("Run compare() first")
return self._unchanged.copy()
def to_dataframe(self) -> pd.DataFrame:
"""
Export all differences to a single DataFrame.
Returns:
DataFrame with all differences
"""
if self.comparison_result is None:
raise ValueError("Run compare() first")
rows = []
# Added rows
for _, row in self._added.iterrows():
for col in row.index:
rows.append({
"change_type": "added",
"key": None,
"column": col,
"old_value": None,
"new_value": row[col]
})
# Removed rows
for _, row in self._removed.iterrows():
for col in row.index:
rows.append({
"change_type": "removed",
"key": None,
"column": col,
"old_value": row[col],
"new_value": None
})
# Changed values
for _, row in self._changed.iterrows():
rows.append({
"change_type": "modified",
"key": row["_key"],
"column": row["_column"],
"old_value": row["_old_value"],
"new_value": row["_new_value"]
})
return pd.DataFrame(rows)
def generate_report(self, output: str, format: str = "html") -> str:
"""
Generate comparison report.
Args:
output: Output file path
format: "html", "csv", or "json"
Returns:
Output file path
"""
if self.comparison_result is None:
raise ValueError("Run compare() first")
if format == "html":
self._generate_html_report(output)
elif format == "csv":
self._generate_csv_report(output)
elif format == "json":
self._generate_json_report(output)
else:
raise ValueError(f"Unknown format: {format}")
return output
def _generate_html_report(self, output: str):
"""Generate HTML report."""
summary = self.comparison_result["summary"]
schema = self.comparison_result["schema_changes"]
html_content = f"""<!DOCTYPE html>
<html>
<head>
<title>Dataset Comparison Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }}
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; }}
h1 {{ color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }}
h2 {{ color: #555; margin-top: 30px; }}
.summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin: 20px 0; }}
.stat {{ background: #f9f9f9; padding: 15px; border-radius: 5px; text-align: center; }}
.stat-value {{ font-size: 24px; font-weight: bold; color: #333; }}
.stat-label {{ color: #666; font-size: 12px; }}
.added {{ background: #e8f5e9; }}
.removed {{ background: #ffebee; }}
.changed {{ background: #fff3e0; }}
table {{ width: 100%; border-collapse: collapse; margin: 15px 0; }}
th, td {{ border: 1px solid #ddd; padding: 10px; text-align: left; }}
th {{ background: #f5f5f5; }}
.badge {{ display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 11px; }}
.badge-added {{ background: #4CAF50; color: white; }}
.badge-removed {{ background: #f44336; color: white; }}
.badge-changed {{ background: #ff9800; color: white; }}
.timestamp {{ color: #999; font-size: 12px; margin-top: 20px; }}
</style>
</head>
<body>
<div class="container">
<h1>Dataset Comparison Report</h1>
<h2>Summary</h2>
<div class="summary">
<div class="stat">
<div class="stat-value">{summary['old_rows']:,}</div>
<div class="stat-label">OLD ROWS</div>
</div>
<div class="stat">
<div class="stat-value">{summary['new_rows']:,}</div>
<div class="stat-label">NEW ROWS</div>
</div>
<div class="stat added">
<div class="stat-value">{summary['added_count']:,}</div>
<div class="stat-label">ADDED</div>
</div>
<div class="stat removed">
<div class="stat-value">{summary['removed_count']:,}</div>
<div class="stat-label">REMOVED</div>
</div>
<div class="stat changed">
<div class="stat-value">{summary['changed_count']:,}</div>
<div class="stat-label">CHANGED</div>
</div>
<div class="stat">
<div class="stat-value">{summary['unchanged_count']:,}</div>
<div class="stat-label">UNCHANGED</div>
</div>
</div>
<h2>Schema Changes</h2>
<table>
<tr>
<th>Change Type</th>
<th>Details</th>
</tr>
<tr>
<td>Added Columns</td>
<td>{', '.join(schema['added_columns']) or 'None'}</td>
</tr>
<tr>
<td>Removed Columns</td>
<td>{', '.join(schema['removed_columns']) or 'None'}</td>
</tr>
<tr>
<td>Type Changes</td>
<td>{self._format_type_changes(schema['type_changes'])}</td>
</tr>
</table>
"""
# Added rows
if len(self._added) > 0:
html_content += """
<h2><span class="badge badge-added">ADDED</span> New Rows</h2>
<table>
"""
html_content += "<tr>" + "".join(f"<th>{html.escape(str(c))}</th>" for c in self._added.columns) + "</tr>\n"
for _, row in self._added.head(100).iterrows():
html_content += "<tr>" + "".join(f"<td>{html.escape(str(v))}</td>" for v in row) + "</tr>\n"
html_content += "</table>\n"
if len(self._added) > 100:
html_content += f"<p>... and {len(self._added) - 100} more rows</p>\n"
# Removed rows
if len(self._removed) > 0:
html_content += """
<h2><span class="badge badge-removed">REMOVED</span> Deleted Rows</h2>
<table>
"""
html_content += "<tr>" + "".join(f"<th>{html.escape(str(c))}</th>" for c in self._removed.columns) + "</tr>\n"
for _, row in self._removed.head(100).iterrows():
html_content += "<tr>" + "".join(f"<td>{html.escape(str(v))}</td>" for v in row) + "</tr>\n"
html_content += "</table>\n"
if len(self._removed) > 100:
html_content += f"<p>... and {len(self._removed) - 100} more rows</p>\n"
# Changed rows
if len(self._changed) > 0:
html_content += """
<h2><span class="badge badge-changed">CHANGED</span> Modified Values</h2>
<table>
<tr>
<th>Key</th>
<th>Column</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
"""
for _, row in self._changed.head(100).iterrows():
html_content += f""" <tr>
<td>{html.escape(str(row['_key']))}</td>
<td>{html.escape(str(row['_column']))}</td>
<td>{html.escape(str(row['_old_value']))}</td>
<td>{html.escape(str(row['_new_value']))}</td>
</tr>
"""
html_content += "</table>\n"
if len(self._changed) > 100:
html_content += f"<p>... and {len(self._changed) - 100} more changes</p>\n"
html_content += f"""
<p class="timestamp">Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
</body>
</html>"""
with open(output, 'w') as f:
f.write(html_content)
def _format_type_changes(self, changes: List[Dict]) -> str:
"""Format type changes for display."""
if not changes:
return "None"
return "; ".join(f"{c['column']}: {c['old_type']} → {c['new_type']}" for c in changes)
def _generate_csv_report(self, output: str):
"""Generate CSV report."""
df = self.to_dataframe()
df.to_csv(output, index=False)
def _generate_json_report(self, output: str):
"""Generate JSON report."""
result = {
"comparison": self.comparison_result,
"added_rows": self._added.to_dict(orient="records"),
"removed_rows": self._removed.to_dict(orient="records"),
"changed_values": self._changed.to_dict(orient="records") if len(self._changed) > 0 else []
}
with open(output, 'w') as f:
json.dump(result, f, indent=2, default=str)
def summary(self) -> str:
"""
Generate text summary.
Returns:
Summary string
"""
if self.comparison_result is None:
raise ValueError("Run compare() first")
s = self.comparison_result["summary"]
lines = [
"=" * 50,
"DATASET COMPARISON SUMMARY",
"=" * 50,
f"Old dataset: {s['old_rows']:,} rows",
f"New dataset: {s['new_rows']:,} rows",
"",
"DIFFERENCES",
"-" * 30,
f"Added rows: {s['added_count']:,}",
f"Removed rows: {s['removed_count']:,}",
f"Changed rows: {s['changed_count']:,}",
f"Unchanged rows: {s['unchanged_count']:,}",
"",
f"Total differences: {s['total_differences']:,}",
"",
"SCHEMA CHANGES",
"-" * 30
]
schema = self.comparison_result["schema_changes"]
if schema["added_columns"]:
lines.append(f"Added columns: {', '.join(schema['added_columns'])}")
if schema["removed_columns"]:
lines.append(f"Removed columns: {', '.join(schema['removed_columns'])}")
if schema["type_changes"]:
for tc in schema["type_changes"]:
lines.append(f"Type change: {tc['column']} ({tc['old_type']} -> {tc['new_type']})")
if not any([schema["added_columns"], schema["removed_columns"], schema["type_changes"]]):
lines.append("No schema changes")
lines.append("=" * 50)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Dataset Comparer - Compare two datasets to find differences"
)
parser.add_argument("--old", required=True, help="Old/baseline dataset path")
parser.add_argument("--new", required=True, help="New dataset path")
parser.add_argument("--key", "-k", help="Key column(s) for matching (comma-separated)")
parser.add_argument("--ignore", help="Columns to ignore (comma-separated)")
parser.add_argument("--columns", "-c", help="Only compare these columns (comma-separated)")
parser.add_argument("--report", "-r", help="Output report file (html/csv/json based on extension)")
parser.add_argument("--output", "-o", help="Output CSV file for differences")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
comparer = DatasetComparer()
comparer.load(args.old, args.new)
# Parse arguments
key_columns = args.key.split(',') if args.key else None
ignore_columns = args.ignore.split(',') if args.ignore else None
compare_columns = args.columns.split(',') if args.columns else None
# Run comparison
result = comparer.compare(
key_columns=key_columns,
ignore_columns=ignore_columns,
compare_columns=compare_columns
)
if args.json:
output = {
"comparison": result,
"added_rows": comparer.get_added_rows().to_dict(orient="records"),
"removed_rows": comparer.get_removed_rows().to_dict(orient="records"),
"changed_values": comparer.get_changed_rows().to_dict(orient="records")
}
print(json.dumps(output, indent=2, default=str))
else:
print(comparer.summary())
# Generate report
if args.report:
ext = args.report.split('.')[-1].lower()
format_map = {'html': 'html', 'csv': 'csv', 'json': 'json'}
fmt = format_map.get(ext, 'html')
comparer.generate_report(args.report, format=fmt)
print(f"\nReport saved to: {args.report}")
# Export differences
if args.output:
df = comparer.to_dataframe()
df.to_csv(args.output, index=False)
print(f"Differences exported to: {args.output}")
if __name__ == "__main__":
main()
matplotlib>=3.7.0
numpy>=1.24.0
openpyxl>=3.1.0
pandas>=2.0.0
pyyaml>=6.0
reportlab>=4.0.0
scikit-learn>=1.3.0
scipy>=1.10.0
seaborn>=0.12.0
statsmodels>=0.14.0
textblob>=0.17.0
toml>=0.10.0
wordcloud>=1.9.0
xmltodict>=0.13.0