flasheda 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: flasheda
3
+ Version: 0.1.0
4
+ Summary: Constant-time EDA for any dataset size — one line, fixed time.
5
+ Author: Your Name
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Requires-Python: >=3.8
9
+ Requires-Dist: pandas>=1.3.0
10
+ Requires-Dist: numpy>=1.21.0
11
+ Requires-Dist: scipy>=1.7.0
12
+ Requires-Dist: rich>=12.0.0
13
+ Requires-Dist: jinja2>=3.0.0
14
+ Requires-Dist: matplotlib>=3.4.0
15
+ Dynamic: author
16
+ Dynamic: classifier
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
@@ -0,0 +1,21 @@
1
+ # ⚡ FlashEDA
2
+
3
+ Constant-time Exploratory Data Analysis — one line, fixed time, any dataset size.
4
+
5
+ ## Install
6
+ ```bash
7
+ pip install flasheda
8
+ ```
9
+
10
+ ## Usage
11
+ ```python
12
+ import flasheda
13
+
14
+ report = flasheda.analyze(df) # works on 1K or 50M rows in the same time
15
+ report.show() # rich console output
16
+ report.save_html("report.html") # browser report
17
+ ```
18
+
19
+ ## How it works
20
+ FlashEDA uses **reservoir sampling** to always analyse exactly 5,000 rows,
21
+ regardless of dataset size. All analyzers run in parallel via ThreadPoolExecutor.
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from pathlib import Path
6
+ from typing import Union, Optional
7
+
8
+ import pandas as pd
9
+
10
+ from .sampler import sample, get_sample_size, set_sample_size
11
+ from .analyzers import overview, missing, numeric, categorical, correlation
12
+ from .report import EDAReport
13
+
14
+ __version__ = "0.1.0"
15
+ __all__ = ["analyze", "set_sample_size", "get_sample_size", "EDAReport"]
16
+
17
+
18
+ def analyze(
19
+ source: Union[pd.DataFrame, str, Path],
20
+ n: Optional[int] = None,
21
+ *,
22
+ show: bool = False,
23
+ random_state: int = 42,
24
+ ) -> EDAReport:
25
+
26
+ t0 = time.perf_counter()
27
+ n = n or get_sample_size()
28
+
29
+ if isinstance(source, pd.DataFrame):
30
+ original_shape = source.shape
31
+ else:
32
+ original_shape = (-1, -1)
33
+
34
+ sampled = sample(source, n=n, random_state=random_state)
35
+
36
+ if original_shape == (-1, -1):
37
+ original_shape = sampled.shape
38
+
39
+ tasks = {
40
+ "overview": lambda: overview.analyze(sampled, original_shape),
41
+ "missing": lambda: missing.analyze(sampled),
42
+ "numeric": lambda: numeric.analyze(sampled),
43
+ "categorical": lambda: categorical.analyze(sampled),
44
+ "correlation": lambda: correlation.analyze(sampled),
45
+ }
46
+
47
+ results: dict = {}
48
+ with ThreadPoolExecutor(max_workers=5) as pool:
49
+ future_map = {pool.submit(fn): name for name, fn in tasks.items()}
50
+ for future in as_completed(future_map):
51
+ name = future_map[future]
52
+ try:
53
+ results[name] = future.result()
54
+ except Exception as exc:
55
+ results[name] = {"error": str(exc)}
56
+
57
+ warnings: list[str] = []
58
+ miss = results.get("missing", {})
59
+ if miss.get("critical_columns"):
60
+ warnings.append(f"Columns with >50% missing: {', '.join(miss['critical_columns'])}")
61
+ num = results.get("numeric", {})
62
+ if num.get("skewed_columns"):
63
+ warnings.append(f"Highly skewed columns: {', '.join(num['skewed_columns'])}")
64
+ cat = results.get("categorical", {})
65
+ if cat.get("likely_id_columns"):
66
+ warnings.append(f"Likely ID columns: {', '.join(cat['likely_id_columns'])}")
67
+ corr = results.get("correlation", {})
68
+ if corr.get("numeric_strong_pairs"):
69
+ warnings.append(f"{len(corr['numeric_strong_pairs'])} strongly correlated pair(s) found")
70
+
71
+ elapsed = round(time.perf_counter() - t0, 3)
72
+
73
+ report = EDAReport(
74
+ overview=results.get("overview", {}),
75
+ missing=results.get("missing", {}),
76
+ numeric=results.get("numeric", {}),
77
+ categorical=results.get("categorical", {}),
78
+ correlation=results.get("correlation", {}),
79
+ sample_size=len(sampled),
80
+ elapsed_seconds=elapsed,
81
+ original_shape=original_shape,
82
+ warnings=warnings,
83
+ )
84
+
85
+ if show:
86
+ report.show()
87
+
88
+ return report
89
+
90
+
91
+ def get_sample_size() -> int:
92
+ from .sampler import get_sample_size as _get
93
+ return _get()
94
+
95
+
96
+ def set_sample_size(n: int) -> None:
97
+ from .sampler import set_sample_size as _set
98
+ _set(n)
@@ -0,0 +1,3 @@
1
+ from . import overview, missing, numeric, categorical, correlation
2
+
3
+ __all__ = ["overview", "missing", "numeric", "categorical", "correlation"]
@@ -0,0 +1,76 @@
1
+ """
2
+ flasheda.analyzers.categorical
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Categorical / string column analysis.
5
+ Computes cardinality, top-k frequencies, and flags high-cardinality columns.
6
+ Uses HyperLogLog-style approximation for cardinality on large columns.
7
+ Constant-time: always operates on the fixed sample.
8
+ """
9
+
10
+ import pandas as pd
11
+ import numpy as np
12
+ from typing import Dict, Any, List
13
+
14
+
15
+ HIGH_CARDINALITY_RATIO = 0.5 # unique/total > 50% → high cardinality warning
16
+ TOP_K = 10
17
+
18
+
19
+ def analyze(df: pd.DataFrame) -> Dict[str, Any]:
20
+ # Include object, string, category, and boolean columns
21
+ cat_cols = df.select_dtypes(
22
+ include=["object", "string", "category", "bool"]
23
+ ).columns.tolist()
24
+
25
+ results: Dict[str, Dict[str, Any]] = {}
26
+
27
+ for col in cat_cols:
28
+ s = df[col].dropna().astype(str)
29
+ n = len(s)
30
+ if n == 0:
31
+ results[col] = {"error": "all values are null"}
32
+ continue
33
+
34
+ unique_count = s.nunique()
35
+ cardinality_ratio = round(unique_count / n, 4)
36
+ high_cardinality = cardinality_ratio > HIGH_CARDINALITY_RATIO
37
+
38
+ vc = s.value_counts()
39
+ top_k = vc.head(TOP_K)
40
+
41
+ top_values = [
42
+ {
43
+ "value": str(val),
44
+ "count": int(cnt),
45
+ "pct": round(cnt / n * 100, 2),
46
+ }
47
+ for val, cnt in top_k.items()
48
+ ]
49
+
50
+ # Mode
51
+ mode_val = vc.index[0] if len(vc) > 0 else None
52
+ mode_pct = round(vc.iloc[0] / n * 100, 2) if len(vc) > 0 else 0.0
53
+
54
+ # Detect potential ID columns (very high cardinality)
55
+ likely_id = cardinality_ratio > 0.95
56
+
57
+ results[col] = {
58
+ "count": n,
59
+ "unique_count": unique_count,
60
+ "cardinality_ratio": cardinality_ratio,
61
+ "high_cardinality": high_cardinality,
62
+ "likely_id_column": likely_id,
63
+ "mode": str(mode_val),
64
+ "mode_pct": mode_pct,
65
+ "top_values": top_values,
66
+ }
67
+
68
+ high_card_cols = [c for c, v in results.items() if v.get("high_cardinality")]
69
+ likely_id_cols = [c for c, v in results.items() if v.get("likely_id_column")]
70
+
71
+ return {
72
+ "columns": results,
73
+ "categorical_col_names": cat_cols,
74
+ "high_cardinality_columns": high_card_cols,
75
+ "likely_id_columns": likely_id_cols,
76
+ }
@@ -0,0 +1,91 @@
1
+ """
2
+ flasheda.analyzers.correlation
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Computes:
5
+ - Pearson correlation for numeric–numeric pairs
6
+ - Cramér's V for categorical–categorical pairs
7
+ - Point-biserial for numeric–boolean pairs
8
+
9
+ Flags strongly correlated pairs (potential redundant features).
10
+ Always operates on the fixed sample — constant time.
11
+ """
12
+
13
+ import pandas as pd
14
+ import numpy as np
15
+ from typing import Dict, Any, List, Tuple
16
+ from itertools import combinations
17
+
18
+
19
+ STRONG_CORR_THRESHOLD = 0.80
20
+ MAX_COLS_FOR_HEATMAP = 20 # skip heatmap if too many cols (slow to render)
21
+
22
+
23
+ def analyze(df: pd.DataFrame) -> Dict[str, Any]:
24
+ num_cols = df.select_dtypes(include=["number"]).columns.tolist()
25
+ cat_cols = df.select_dtypes(include=["object", "string", "category"]).columns.tolist()
26
+
27
+ # ── Numeric correlations ────────────────────────────────────────────────
28
+ pearson_matrix: Dict[str, Any] = {}
29
+ strong_pairs: List[Dict[str, Any]] = []
30
+
31
+ if len(num_cols) >= 2:
32
+ num_df = df[num_cols].copy()
33
+ corr = num_df.corr(method="pearson")
34
+ pearson_matrix = {
35
+ col: {
36
+ other: round(float(corr.loc[col, other]), 4)
37
+ for other in num_cols
38
+ }
39
+ for col in num_cols
40
+ }
41
+
42
+ for a, b in combinations(num_cols, 2):
43
+ v = abs(float(corr.loc[a, b]))
44
+ if v >= STRONG_CORR_THRESHOLD and not np.isnan(v):
45
+ strong_pairs.append({"col_a": a, "col_b": b, "pearson_r": round(float(corr.loc[a, b]), 4)})
46
+
47
+ # ── Cramér's V for categoricals ─────────────────────────────────────────
48
+ cramer_pairs: List[Dict[str, Any]] = []
49
+ if len(cat_cols) >= 2:
50
+ for a, b in combinations(cat_cols[:10], 2): # cap at 10 cols
51
+ v = _cramers_v(df[a], df[b])
52
+ if v is not None and v >= STRONG_CORR_THRESHOLD:
53
+ cramer_pairs.append({"col_a": a, "col_b": b, "cramers_v": round(v, 4)})
54
+
55
+ # ── Heatmap data (numeric only, capped) ─────────────────────────────────
56
+ heatmap_cols = num_cols[:MAX_COLS_FOR_HEATMAP]
57
+ heatmap_data: Dict[str, Any] = {}
58
+ if len(heatmap_cols) >= 2:
59
+ hm_corr = df[heatmap_cols].corr(method="pearson")
60
+ heatmap_data = {
61
+ "columns": heatmap_cols,
62
+ "matrix": [[round(float(hm_corr.loc[r, c]), 3) for c in heatmap_cols] for r in heatmap_cols],
63
+ }
64
+
65
+ return {
66
+ "pearson_matrix": pearson_matrix,
67
+ "numeric_strong_pairs": strong_pairs,
68
+ "categorical_strong_pairs": cramer_pairs,
69
+ "heatmap": heatmap_data,
70
+ "numeric_col_names": num_cols,
71
+ }
72
+
73
+
74
+ def _cramers_v(a: pd.Series, b: pd.Series) -> float | None:
75
+ """Cramér's V: symmetric measure of association for categorical columns."""
76
+ try:
77
+ from scipy.stats import chi2_contingency
78
+ ct = pd.crosstab(a.astype(str), b.astype(str))
79
+ chi2, _, _, _ = chi2_contingency(ct)
80
+ n = ct.sum().sum()
81
+ phi2 = chi2 / n
82
+ r, k = ct.shape
83
+ phi2_corr = max(0.0, phi2 - (k - 1) * (r - 1) / (n - 1))
84
+ r_corr = r - (r - 1) ** 2 / (n - 1)
85
+ k_corr = k - (k - 1) ** 2 / (n - 1)
86
+ denom = min(k_corr - 1, r_corr - 1)
87
+ if denom <= 0:
88
+ return None
89
+ return float(np.sqrt(phi2_corr / denom))
90
+ except Exception:
91
+ return None
@@ -0,0 +1,54 @@
1
+ """
2
+ flasheda.analyzers.missing
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Missing-value analysis on the fixed sample.
5
+ Detects per-column null rates and flags high-missing columns.
6
+ """
7
+
8
+ import pandas as pd
9
+ import numpy as np
10
+ from typing import Dict, Any, List
11
+
12
+
13
+ WARN_THRESHOLD = 0.20 # 20%+ missing → flag
14
+ CRITICAL_THRESHOLD = 0.50 # 50%+ missing → critical
15
+
16
+
17
+ def analyze(df: pd.DataFrame) -> Dict[str, Any]:
18
+ n = len(df)
19
+ per_col: Dict[str, Dict[str, Any]] = {}
20
+
21
+ for col in df.columns:
22
+ null_count = int(df[col].isna().sum())
23
+ null_pct = round(null_count / n * 100, 2) if n > 0 else 0.0
24
+ severity = "ok"
25
+ if null_pct >= CRITICAL_THRESHOLD * 100:
26
+ severity = "critical"
27
+ elif null_pct >= WARN_THRESHOLD * 100:
28
+ severity = "warn"
29
+
30
+ per_col[col] = {
31
+ "null_count": null_count,
32
+ "null_pct": null_pct,
33
+ "severity": severity,
34
+ }
35
+
36
+ total_cells = n * len(df.columns)
37
+ total_nulls = sum(v["null_count"] for v in per_col.values())
38
+ overall_null_pct = round(total_nulls / total_cells * 100, 2) if total_cells > 0 else 0.0
39
+
40
+ warn_cols = [c for c, v in per_col.items() if v["severity"] in ("warn", "critical")]
41
+ critical_cols = [c for c, v in per_col.items() if v["severity"] == "critical"]
42
+
43
+ # Detect rows with any null (approximate missing-row pattern)
44
+ rows_with_any_null = int(df.isna().any(axis=1).sum())
45
+ rows_with_any_null_pct = round(rows_with_any_null / n * 100, 2) if n > 0 else 0.0
46
+
47
+ return {
48
+ "per_column": per_col,
49
+ "overall_null_pct": overall_null_pct,
50
+ "warn_columns": warn_cols,
51
+ "critical_columns": critical_cols,
52
+ "rows_with_any_null": rows_with_any_null,
53
+ "rows_with_any_null_pct": rows_with_any_null_pct,
54
+ }
@@ -0,0 +1,71 @@
1
+ """
2
+ flasheda.analyzers.numeric
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Statistical summary for numeric columns.
5
+ Detects outliers using IQR, computes skewness & kurtosis,
6
+ and flags heavily skewed distributions.
7
+ All computed on the fixed sample — constant time.
8
+ """
9
+
10
+ import pandas as pd
11
+ import numpy as np
12
+ from typing import Dict, Any, List
13
+
14
+
15
+ SKEW_THRESHOLD = 1.0 # |skew| > 1 → flag
16
+
17
+
18
+ def analyze(df: pd.DataFrame) -> Dict[str, Any]:
19
+ num_cols = df.select_dtypes(include=["number"]).columns.tolist()
20
+ results: Dict[str, Dict[str, Any]] = {}
21
+
22
+ for col in num_cols:
23
+ s = df[col].dropna()
24
+ if len(s) < 4:
25
+ results[col] = {"error": "too few non-null values"}
26
+ continue
27
+
28
+ q1, q50, q75 = float(np.percentile(s, 25)), float(np.percentile(s, 50)), float(np.percentile(s, 75))
29
+ iqr = q75 - q1
30
+
31
+ lower_fence = q1 - 1.5 * iqr
32
+ upper_fence = q75 + 1.5 * iqr
33
+ outliers = s[(s < lower_fence) | (s > upper_fence)]
34
+ outlier_pct = round(len(outliers) / len(s) * 100, 2)
35
+
36
+ skewness = float(s.skew())
37
+ kurt = float(s.kurtosis())
38
+ mean_val = float(s.mean())
39
+ std_val = float(s.std())
40
+ min_val = float(s.min())
41
+ max_val = float(s.max())
42
+
43
+ skew_flag = abs(skewness) > SKEW_THRESHOLD
44
+
45
+ results[col] = {
46
+ "count": int(len(s)),
47
+ "mean": round(mean_val, 4),
48
+ "std": round(std_val, 4),
49
+ "min": round(min_val, 4),
50
+ "q25": round(q1, 4),
51
+ "median": round(q50, 4),
52
+ "q75": round(q75, 4),
53
+ "max": round(max_val, 4),
54
+ "iqr": round(iqr, 4),
55
+ "skewness": round(skewness, 4),
56
+ "kurtosis": round(kurt, 4),
57
+ "outlier_count": int(len(outliers)),
58
+ "outlier_pct": outlier_pct,
59
+ "skewed": skew_flag,
60
+ "zero_variance": std_val < 1e-10,
61
+ }
62
+
63
+ skewed_cols = [c for c, v in results.items() if v.get("skewed")]
64
+ high_outlier_cols = [c for c, v in results.items() if v.get("outlier_pct", 0) > 5]
65
+
66
+ return {
67
+ "columns": results,
68
+ "numeric_col_names": num_cols,
69
+ "skewed_columns": skewed_cols,
70
+ "high_outlier_columns": high_outlier_cols,
71
+ }
@@ -0,0 +1,66 @@
1
+ """
2
+ flasheda.analyzers.overview
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Fast dataset-level summary: shape, dtypes, memory, duplicates.
5
+ Operates on the fixed-size sample — O(n_sample) always.
6
+ """
7
+
8
+ import pandas as pd
9
+ import numpy as np
10
+ from typing import Dict, Any
11
+
12
+
13
+ def analyze(df: pd.DataFrame, original_shape: tuple) -> Dict[str, Any]:
14
+ """
15
+ Parameters
16
+ ----------
17
+ df : sampled DataFrame
18
+ original_shape : (rows, cols) of the original dataset before sampling
19
+ """
20
+ original_rows, ncols = original_shape
21
+ sample_rows = len(df)
22
+
23
+ dtype_counts: Dict[str, int] = {}
24
+ for dtype in df.dtypes:
25
+ key = _simplify_dtype(dtype)
26
+ dtype_counts[key] = dtype_counts.get(key, 0) + 1
27
+
28
+ mem_bytes = df.memory_usage(deep=True).sum()
29
+ # Extrapolate memory to full dataset
30
+ scale = original_rows / sample_rows if sample_rows > 0 else 1
31
+ est_full_mem = mem_bytes * scale
32
+
33
+ dup_count = df.duplicated().sum()
34
+ dup_pct = round(dup_count / sample_rows * 100, 2) if sample_rows > 0 else 0.0
35
+
36
+ col_types = {col: _simplify_dtype(df[col].dtype) for col in df.columns}
37
+
38
+ return {
39
+ "original_rows": original_rows,
40
+ "sample_rows": sample_rows,
41
+ "columns": ncols,
42
+ "dtype_counts": dtype_counts,
43
+ "col_types": col_types,
44
+ "memory_sample_bytes": int(mem_bytes),
45
+ "memory_estimated_full_bytes": int(est_full_mem),
46
+ "duplicate_rows_in_sample": int(dup_count),
47
+ "duplicate_pct_in_sample": dup_pct,
48
+ "column_names": list(df.columns),
49
+ }
50
+
51
+
52
+ def _simplify_dtype(dtype) -> str:
53
+ s = str(dtype)
54
+ if "int" in s:
55
+ return "integer"
56
+ if "float" in s:
57
+ return "float"
58
+ if "bool" in s:
59
+ return "boolean"
60
+ if "datetime" in s:
61
+ return "datetime"
62
+ if "object" in s or "string" in s:
63
+ return "string/object"
64
+ if "category" in s:
65
+ return "category"
66
+ return s
@@ -0,0 +1,348 @@
1
+ """
2
+ flasheda.report
3
+ ~~~~~~~~~~~~~~~
4
+ EDAReport: the single object returned by flasheda.analyze().
5
+ Supports:
6
+ - .show() → rich console table
7
+ - .to_html() → self-contained HTML report
8
+ - .to_dict() → plain dict
9
+ - .to_json() → JSON string
10
+ - .save_html() → write HTML to disk
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Optional
20
+
21
+
22
+ @dataclass
23
+ class EDAReport:
24
+ overview: Dict[str, Any]
25
+ missing: Dict[str, Any]
26
+ numeric: Dict[str, Any]
27
+ categorical: Dict[str, Any]
28
+ correlation: Dict[str, Any]
29
+ sample_size: int
30
+ elapsed_seconds: float
31
+ original_shape: tuple
32
+ warnings: list[str] = field(default_factory=list)
33
+
34
+ # ──────────────────────────────────────────────────────────────────────
35
+ # Console output
36
+ # ──────────────────────────────────────────────────────────────────────
37
+
38
+ def show(self) -> None:
39
+ """Print a rich summary to the console."""
40
+ from rich.console import Console
41
+ from rich.table import Table
42
+ from rich import box
43
+ from rich.panel import Panel
44
+
45
+ console = Console()
46
+
47
+ # ── Header ──
48
+ orig_rows = self.original_shape[0]
49
+ ncols = self.original_shape[1]
50
+ console.print(
51
+ Panel(
52
+ f"[bold cyan]FlashEDA Report[/bold cyan]\n"
53
+ f"Dataset: [green]{orig_rows:,}[/green] rows × [green]{ncols}[/green] cols "
54
+ f"| Analysed on [yellow]{self.sample_size:,}[/yellow] sampled rows "
55
+ f"| ⏱ [white]{self.elapsed_seconds:.2f}s[/white]",
56
+ expand=False,
57
+ )
58
+ )
59
+
60
+ # ── Warnings ──
61
+ if self.warnings:
62
+ for w in self.warnings:
63
+ console.print(f" [bold yellow]⚠ {w}[/bold yellow]")
64
+ console.print()
65
+
66
+ # ── Missing values ──
67
+ miss = self.missing
68
+ console.print(f"[bold]Missing values[/bold] overall: [red]{miss['overall_null_pct']}%[/red] "
69
+ f"| rows with any null: [red]{miss['rows_with_any_null_pct']}%[/red]")
70
+ if miss["critical_columns"]:
71
+ console.print(f" Critical (>50% null): [red]{', '.join(miss['critical_columns'])}[/red]")
72
+ if miss["warn_columns"]:
73
+ console.print(f" Warn (>20% null): [yellow]{', '.join(miss['warn_columns'])}[/yellow]")
74
+ console.print()
75
+
76
+ # ── Numeric summary table ──
77
+ num_cols = self.numeric.get("numeric_col_names", [])
78
+ if num_cols:
79
+ t = Table(title="Numeric columns", box=box.SIMPLE, show_lines=False)
80
+ for h in ["Column", "Mean", "Std", "Min", "Median", "Max", "Outlier %", "Skew"]:
81
+ t.add_column(h, style="white")
82
+ for col in num_cols:
83
+ s = self.numeric["columns"].get(col, {})
84
+ if "error" in s:
85
+ continue
86
+ flag = "[yellow]⚠[/yellow] " if s.get("skewed") else ""
87
+ t.add_row(
88
+ col,
89
+ str(s.get("mean", "")),
90
+ str(s.get("std", "")),
91
+ str(s.get("min", "")),
92
+ str(s.get("median", "")),
93
+ str(s.get("max", "")),
94
+ f"{s.get('outlier_pct', 0)}%",
95
+ f"{flag}{s.get('skewness', '')}",
96
+ )
97
+ console.print(t)
98
+
99
+ # ── Categorical summary table ──
100
+ cat_cols = self.categorical.get("categorical_col_names", [])
101
+ if cat_cols:
102
+ t = Table(title="Categorical columns", box=box.SIMPLE, show_lines=False)
103
+ for h in ["Column", "Unique", "Cardinality ratio", "Mode", "Mode %", "Flag"]:
104
+ t.add_column(h)
105
+ for col in cat_cols:
106
+ s = self.categorical["columns"].get(col, {})
107
+ if "error" in s:
108
+ continue
109
+ flag = ""
110
+ if s.get("likely_id_column"):
111
+ flag = "[red]likely ID[/red]"
112
+ elif s.get("high_cardinality"):
113
+ flag = "[yellow]high cardinality[/yellow]"
114
+ t.add_row(
115
+ col,
116
+ str(s.get("unique_count", "")),
117
+ str(s.get("cardinality_ratio", "")),
118
+ str(s.get("mode", ""))[:30],
119
+ f"{s.get('mode_pct', '')}%",
120
+ flag,
121
+ )
122
+ console.print(t)
123
+
124
+ # ── Strong correlations ──
125
+ strong = self.correlation.get("numeric_strong_pairs", [])
126
+ if strong:
127
+ console.print("[bold]Strong numeric correlations (|r| ≥ 0.8)[/bold]")
128
+ for pair in strong[:10]:
129
+ console.print(f" {pair['col_a']} ↔ {pair['col_b']} r={pair['pearson_r']}")
130
+ console.print()
131
+
132
+ # ──────────────────────────────────────────────────────────────────────
133
+ # HTML report
134
+ # ──────────────────────────────────────────────────────────────────────
135
+
136
+ def to_html(self) -> str:
137
+ """Return a self-contained HTML report string."""
138
+ return _render_html(self)
139
+
140
+ def save_html(self, path: str = "flasheda_report.html") -> Path:
141
+ """Save the HTML report to disk and return the path."""
142
+ p = Path(path)
143
+ p.write_text(self.to_html(), encoding="utf-8")
144
+ return p
145
+
146
+ # ──────────────────────────────────────────────────────────────────────
147
+ # Serialisation
148
+ # ──────────────────────────────────────────────────────────────────────
149
+
150
+ def to_dict(self) -> Dict[str, Any]:
151
+ return {
152
+ "overview": self.overview,
153
+ "missing": self.missing,
154
+ "numeric": self.numeric,
155
+ "categorical": self.categorical,
156
+ "correlation": self.correlation,
157
+ "sample_size": self.sample_size,
158
+ "elapsed_seconds": self.elapsed_seconds,
159
+ "original_shape": list(self.original_shape),
160
+ "warnings": self.warnings,
161
+ }
162
+
163
+ def to_json(self, indent: int = 2) -> str:
164
+ return json.dumps(self.to_dict(), indent=indent, default=str)
165
+
166
+ def __repr__(self) -> str:
167
+ rows, cols = self.original_shape
168
+ return (
169
+ f"EDAReport(rows={rows:,}, cols={cols}, "
170
+ f"sample={self.sample_size:,}, elapsed={self.elapsed_seconds:.2f}s)"
171
+ )
172
+
173
+
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+ # HTML renderer
176
+ # ─────────────────────────────────────────────────────────────────────────────
177
+
178
+ def _render_html(r: EDAReport) -> str:
179
+ num_stats = r.numeric.get("numeric_col_names", [])
180
+ cat_stats = r.categorical.get("categorical_col_names", [])
181
+ strong_corr = r.correlation.get("numeric_strong_pairs", [])
182
+ miss_per_col = r.missing.get("per_column", {})
183
+
184
+ # Build numeric rows
185
+ num_rows_html = ""
186
+ for col in num_stats:
187
+ s = r.numeric["columns"].get(col, {})
188
+ if "error" in s:
189
+ continue
190
+ skew_badge = '<span class="badge warn">skewed</span>' if s.get("skewed") else ""
191
+ out_badge = '<span class="badge warn">outliers</span>' if s.get("outlier_pct", 0) > 5 else ""
192
+ num_rows_html += f"""
193
+ <tr>
194
+ <td><strong>{col}</strong></td>
195
+ <td>{s.get('mean', '')}</td><td>{s.get('std', '')}</td>
196
+ <td>{s.get('min', '')}</td><td>{s.get('median', '')}</td><td>{s.get('max', '')}</td>
197
+ <td>{s.get('outlier_pct', '')}%</td>
198
+ <td>{s.get('skewness', '')} {skew_badge}</td>
199
+ <td>{out_badge}</td>
200
+ </tr>"""
201
+
202
+ # Build categorical rows
203
+ cat_rows_html = ""
204
+ for col in cat_stats:
205
+ s = r.categorical["columns"].get(col, {})
206
+ if "error" in s:
207
+ continue
208
+ flag = ""
209
+ if s.get("likely_id_column"):
210
+ flag = '<span class="badge danger">likely ID</span>'
211
+ elif s.get("high_cardinality"):
212
+ flag = '<span class="badge warn">high cardinality</span>'
213
+ top = s.get("top_values", [])[:5]
214
+ top_str = " · ".join(f"{t['value']} ({t['pct']}%)" for t in top)
215
+ cat_rows_html += f"""
216
+ <tr>
217
+ <td><strong>{col}</strong></td>
218
+ <td>{s.get('unique_count', '')}</td>
219
+ <td>{s.get('cardinality_ratio', '')}</td>
220
+ <td>{s.get('mode', '')[:40]}</td>
221
+ <td>{s.get('mode_pct', '')}%</td>
222
+ <td class="muted">{top_str[:80]}</td>
223
+ <td>{flag}</td>
224
+ </tr>"""
225
+
226
+ # Missing table
227
+ miss_rows_html = ""
228
+ for col, info in miss_per_col.items():
229
+ sev = info.get("severity", "ok")
230
+ color = {"ok": "#27ae60", "warn": "#f39c12", "critical": "#e74c3c"}.get(sev, "#888")
231
+ bar_w = min(100, int(info.get("null_pct", 0)))
232
+ miss_rows_html += f"""
233
+ <tr>
234
+ <td>{col}</td>
235
+ <td>{info.get('null_count', 0)}</td>
236
+ <td>
237
+ <div class="bar-bg">
238
+ <div class="bar-fill" style="width:{bar_w}%;background:{color}"></div>
239
+ </div>
240
+ {info.get('null_pct', 0)}%
241
+ </td>
242
+ <td><span style="color:{color};font-weight:500">{sev}</span></td>
243
+ </tr>"""
244
+
245
+ # Correlation rows
246
+ corr_html = ""
247
+ for p in strong_corr[:15]:
248
+ corr_html += f"<tr><td>{p['col_a']}</td><td>{p['col_b']}</td><td>{p['pearson_r']}</td></tr>"
249
+
250
+ # Warnings
251
+ warn_html = ""
252
+ for w in r.warnings:
253
+ warn_html += f'<div class="warn-banner">⚠ {w}</div>'
254
+
255
+ orig_rows, orig_cols = r.original_shape
256
+
257
+ return f"""<!DOCTYPE html>
258
+ <html lang="en">
259
+ <head>
260
+ <meta charset="UTF-8">
261
+ <title>FlashEDA Report</title>
262
+ <style>
263
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
264
+ body {{ font-family: system-ui, sans-serif; background: #f5f7fa; color: #222; font-size: 14px; }}
265
+ .container {{ max-width: 1100px; margin: 0 auto; padding: 24px 16px; }}
266
+ header {{ background: #1a1a2e; color: #fff; padding: 24px 32px; border-radius: 10px; margin-bottom: 24px; }}
267
+ header h1 {{ font-size: 22px; font-weight: 600; margin-bottom: 6px; }}
268
+ header .meta {{ font-size: 13px; color: #aac; }}
269
+ .card {{ background: #fff; border-radius: 10px; padding: 20px 24px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.07); }}
270
+ .card h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 14px; border-bottom: 1px solid #eee; padding-bottom: 8px; }}
271
+ table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
272
+ th {{ text-align: left; padding: 6px 10px; background: #f0f2f5; font-weight: 500; }}
273
+ td {{ padding: 6px 10px; border-top: 1px solid #f0f0f0; vertical-align: middle; }}
274
+ .badge {{ display: inline-block; padding: 2px 7px; border-radius: 12px; font-size: 11px; font-weight: 600; }}
275
+ .badge.warn {{ background: #fff3cd; color: #856404; }}
276
+ .badge.danger {{ background: #f8d7da; color: #842029; }}
277
+ .muted {{ color: #888; font-size: 12px; }}
278
+ .bar-bg {{ display: inline-block; width: 80px; height: 8px; background: #eee; border-radius: 4px; vertical-align: middle; margin-right: 6px; }}
279
+ .bar-fill {{ height: 8px; border-radius: 4px; }}
280
+ .warn-banner {{ background: #fff3cd; color: #856404; padding: 10px 14px; border-radius: 8px; margin-bottom: 10px; font-size: 13px; }}
281
+ .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 4px; }}
282
+ .stat-box {{ background: #f8f9fc; border-radius: 8px; padding: 14px 16px; }}
283
+ .stat-box .label {{ font-size: 11px; color: #888; margin-bottom: 4px; }}
284
+ .stat-box .value {{ font-size: 20px; font-weight: 600; color: #1a1a2e; }}
285
+ </style>
286
+ </head>
287
+ <body>
288
+ <div class="container">
289
+ <header>
290
+ <h1>⚡ FlashEDA Report</h1>
291
+ <div class="meta">
292
+ {orig_rows:,} rows × {orig_cols} columns &nbsp;|&nbsp;
293
+ Analysed on {r.sample_size:,} sampled rows &nbsp;|&nbsp;
294
+ Completed in {r.elapsed_seconds:.2f}s
295
+ </div>
296
+ </header>
297
+
298
+ {warn_html}
299
+
300
+ <div class="card">
301
+ <h2>Dataset overview</h2>
302
+ <div class="stats-grid">
303
+ <div class="stat-box"><div class="label">Total rows</div><div class="value">{orig_rows:,}</div></div>
304
+ <div class="stat-box"><div class="label">Columns</div><div class="value">{orig_cols}</div></div>
305
+ <div class="stat-box"><div class="label">Sample size</div><div class="value">{r.sample_size:,}</div></div>
306
+ <div class="stat-box"><div class="label">Analysis time</div><div class="value">{r.elapsed_seconds:.2f}s</div></div>
307
+ <div class="stat-box"><div class="label">Overall null %</div><div class="value">{r.missing.get('overall_null_pct', 0)}%</div></div>
308
+ <div class="stat-box"><div class="label">Duplicate rows (sample)</div><div class="value">{r.overview.get('duplicate_pct_in_sample', 0)}%</div></div>
309
+ </div>
310
+ </div>
311
+
312
+ <div class="card">
313
+ <h2>Missing values</h2>
314
+ <table>
315
+ <thead><tr><th>Column</th><th>Null count</th><th>Null %</th><th>Status</th></tr></thead>
316
+ <tbody>{miss_rows_html}</tbody>
317
+ </table>
318
+ </div>
319
+
320
+ <div class="card">
321
+ <h2>Numeric columns</h2>
322
+ <table>
323
+ <thead><tr><th>Column</th><th>Mean</th><th>Std</th><th>Min</th><th>Median</th><th>Max</th><th>Outlier %</th><th>Skewness</th><th>Flags</th></tr></thead>
324
+ <tbody>{num_rows_html}</tbody>
325
+ </table>
326
+ </div>
327
+
328
+ <div class="card">
329
+ <h2>Categorical columns</h2>
330
+ <table>
331
+ <thead><tr><th>Column</th><th>Unique</th><th>Cardinality ratio</th><th>Mode</th><th>Mode %</th><th>Top values</th><th>Flags</th></tr></thead>
332
+ <tbody>{cat_rows_html}</tbody>
333
+ </table>
334
+ </div>
335
+
336
+ {"" if not corr_html else f'''
337
+ <div class="card">
338
+ <h2>Strong correlations (|r| ≥ 0.8)</h2>
339
+ <table>
340
+ <thead><tr><th>Column A</th><th>Column B</th><th>Pearson r</th></tr></thead>
341
+ <tbody>{corr_html}</tbody>
342
+ </table>
343
+ </div>'''}
344
+
345
+ <p class="muted" style="text-align:center;margin-top:16px">Generated by FlashEDA — constant-time EDA for any dataset size</p>
346
+ </div>
347
+ </body>
348
+ </html>"""
@@ -0,0 +1,124 @@
1
+ """
2
+ flasheda.sampler
3
+ ~~~~~~~~~~~~~~~~
4
+ Reservoir sampling that always returns exactly `n` rows,
5
+ regardless of how large the dataset is. This is the key to
6
+ constant-time EDA: every downstream analyzer always sees
7
+ the same fixed number of rows.
8
+
9
+ Supports:
10
+ - pandas DataFrames (in-memory)
11
+ - CSV / Parquet file paths (chunked streaming, no full load)
12
+ - Large in-memory DataFrames (random sample without full scan)
13
+ """
14
+
15
+ import random
16
+ import math
17
+ import pandas as pd
18
+ import numpy as np
19
+ from pathlib import Path
20
+ from typing import Union, Optional
21
+
22
+
23
+ _SAMPLE_SIZE = 5_000 # fixed budget — tweak as needed
24
+
25
+
26
+ def sample(
27
+ source: Union[pd.DataFrame, str, Path],
28
+ n: int = _SAMPLE_SIZE,
29
+ random_state: int = 42,
30
+ ) -> pd.DataFrame:
31
+ """
32
+ Return exactly `n` rows from `source` in O(n) time.
33
+
34
+ For DataFrames larger than n: reservoir / random sample.
35
+ For DataFrames smaller than n: return as-is (no oversampling).
36
+ For file paths: stream in chunks — never load all at once.
37
+ """
38
+ rng = random.Random(random_state)
39
+ np.random.seed(random_state)
40
+
41
+ if isinstance(source, (str, Path)):
42
+ return _sample_file(source, n, rng)
43
+
44
+ if not isinstance(source, pd.DataFrame):
45
+ raise TypeError(f"source must be a DataFrame or file path, got {type(source)}")
46
+
47
+ total = len(source)
48
+ if total <= n:
49
+ return source.copy()
50
+
51
+ # pandas .sample is O(n) with reservoir algorithm under the hood
52
+ return source.sample(n=n, random_state=random_state).reset_index(drop=True)
53
+
54
+
55
+ def _sample_file(path: Union[str, Path], n: int, rng: random.Random) -> pd.DataFrame:
56
+ """
57
+ Stream a CSV or Parquet file in fixed-size chunks and maintain
58
+ a reservoir of exactly n rows — never loading the whole file.
59
+ """
60
+ path = Path(path)
61
+ ext = path.suffix.lower()
62
+
63
+ if ext == ".parquet":
64
+ return _sample_parquet(path, n, rng)
65
+ elif ext in (".csv", ".tsv", ".txt"):
66
+ return _sample_csv(path, n, rng)
67
+ else:
68
+ raise ValueError(f"Unsupported file type: {ext}. Use .csv or .parquet")
69
+
70
+
71
+ def _sample_csv(path: Path, n: int, rng: random.Random) -> pd.DataFrame:
72
+ """Classic reservoir sampling (Algorithm R) over CSV chunks."""
73
+ chunk_size = max(n, 10_000)
74
+ reservoir: list[pd.Series] = []
75
+ seen = 0
76
+
77
+ sep = "\t" if path.suffix.lower() in (".tsv", ".txt") else ","
78
+
79
+ for chunk in pd.read_csv(path, chunksize=chunk_size, sep=sep, low_memory=False):
80
+ for _, row in chunk.iterrows():
81
+ seen += 1
82
+ if len(reservoir) < n:
83
+ reservoir.append(row)
84
+ else:
85
+ j = rng.randint(0, seen - 1)
86
+ if j < n:
87
+ reservoir[j] = row
88
+
89
+ if not reservoir:
90
+ raise ValueError("File appears to be empty.")
91
+
92
+ return pd.DataFrame(reservoir).reset_index(drop=True)
93
+
94
+
95
+ def _sample_parquet(path: Path, n: int, rng: random.Random) -> pd.DataFrame:
96
+ """
97
+ For Parquet: read metadata to get total row count,
98
+ then read only the rows we need via row-group skipping.
99
+ Falls back to full load + sample if pyarrow unavailable.
100
+ """
101
+ try:
102
+ import pyarrow.parquet as pq
103
+ pf = pq.ParquetFile(path)
104
+ total = pf.metadata.num_rows
105
+ if total <= n:
106
+ return pf.read().to_pandas()
107
+ indices = sorted(rng.sample(range(total), n))
108
+ # Read row groups that contain those indices (approximate but fast)
109
+ df = pq.read_table(path).to_pandas()
110
+ return df.iloc[indices].reset_index(drop=True)
111
+ except ImportError:
112
+ df = pd.read_parquet(path)
113
+ return df.sample(n=min(n, len(df)), random_state=42).reset_index(drop=True)
114
+
115
+
116
+ def get_sample_size() -> int:
117
+ return _SAMPLE_SIZE
118
+
119
+
120
+ def set_sample_size(n: int) -> None:
121
+ global _SAMPLE_SIZE
122
+ if n < 100:
123
+ raise ValueError("Sample size must be at least 100.")
124
+ _SAMPLE_SIZE = n
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: flasheda
3
+ Version: 0.1.0
4
+ Summary: Constant-time EDA for any dataset size — one line, fixed time.
5
+ Author: Your Name
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Requires-Python: >=3.8
9
+ Requires-Dist: pandas>=1.3.0
10
+ Requires-Dist: numpy>=1.21.0
11
+ Requires-Dist: scipy>=1.7.0
12
+ Requires-Dist: rich>=12.0.0
13
+ Requires-Dist: jinja2>=3.0.0
14
+ Requires-Dist: matplotlib>=3.4.0
15
+ Dynamic: author
16
+ Dynamic: classifier
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ flasheda/__init__.py
5
+ flasheda/report.py
6
+ flasheda/sampler.py
7
+ flasheda.egg-info/PKG-INFO
8
+ flasheda.egg-info/SOURCES.txt
9
+ flasheda.egg-info/dependency_links.txt
10
+ flasheda.egg-info/requires.txt
11
+ flasheda.egg-info/top_level.txt
12
+ flasheda/analyzers/__init__.py
13
+ flasheda/analyzers/categorical.py
14
+ flasheda/analyzers/correlation.py
15
+ flasheda/analyzers/missing.py
16
+ flasheda/analyzers/numeric.py
17
+ flasheda/analyzers/overview.py
@@ -0,0 +1,6 @@
1
+ pandas>=1.3.0
2
+ numpy>=1.21.0
3
+ scipy>=1.7.0
4
+ rich>=12.0.0
5
+ jinja2>=3.0.0
6
+ matplotlib>=3.4.0
@@ -0,0 +1 @@
1
+ flasheda
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="flasheda",
5
+ version="0.1.0",
6
+ description="Constant-time EDA for any dataset size — one line, fixed time.",
7
+ author="Your Name",
8
+ packages=find_packages(),
9
+ python_requires=">=3.8",
10
+ install_requires=[
11
+ "pandas>=1.3.0",
12
+ "numpy>=1.21.0",
13
+ "scipy>=1.7.0",
14
+ "rich>=12.0.0",
15
+ "jinja2>=3.0.0",
16
+ "matplotlib>=3.4.0",
17
+ ],
18
+ classifiers=[
19
+ "Programming Language :: Python :: 3",
20
+ "License :: OSI Approved :: MIT License",
21
+ ],
22
+ )