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.
- flasheda-0.1.0/PKG-INFO +19 -0
- flasheda-0.1.0/README.md +21 -0
- flasheda-0.1.0/flasheda/__init__.py +98 -0
- flasheda-0.1.0/flasheda/analyzers/__init__.py +3 -0
- flasheda-0.1.0/flasheda/analyzers/categorical.py +76 -0
- flasheda-0.1.0/flasheda/analyzers/correlation.py +91 -0
- flasheda-0.1.0/flasheda/analyzers/missing.py +54 -0
- flasheda-0.1.0/flasheda/analyzers/numeric.py +71 -0
- flasheda-0.1.0/flasheda/analyzers/overview.py +66 -0
- flasheda-0.1.0/flasheda/report.py +348 -0
- flasheda-0.1.0/flasheda/sampler.py +124 -0
- flasheda-0.1.0/flasheda.egg-info/PKG-INFO +19 -0
- flasheda-0.1.0/flasheda.egg-info/SOURCES.txt +17 -0
- flasheda-0.1.0/flasheda.egg-info/dependency_links.txt +1 -0
- flasheda-0.1.0/flasheda.egg-info/requires.txt +6 -0
- flasheda-0.1.0/flasheda.egg-info/top_level.txt +1 -0
- flasheda-0.1.0/pyproject.toml +3 -0
- flasheda-0.1.0/setup.cfg +4 -0
- flasheda-0.1.0/setup.py +22 -0
flasheda-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
flasheda-0.1.0/README.md
ADDED
|
@@ -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,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 |
|
|
293
|
+
Analysed on {r.sample_size:,} sampled rows |
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flasheda
|
flasheda-0.1.0/setup.cfg
ADDED
flasheda-0.1.0/setup.py
ADDED
|
@@ -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
|
+
)
|