noweda 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- noweda/__init__.py +3 -0
- noweda/accessor.py +31 -0
- noweda/cli.py +29 -0
- noweda/core/__init__.py +0 -0
- noweda/core/engine.py +22 -0
- noweda/insights/__init__.py +0 -0
- noweda/insights/generator.py +151 -0
- noweda/io.py +15 -0
- noweda/plugins/__init__.py +21 -0
- noweda/plugins/base.py +5 -0
- noweda/plugins/correlation.py +10 -0
- noweda/plugins/duplicates.py +23 -0
- noweda/plugins/encoding.py +24 -0
- noweda/plugins/missing.py +10 -0
- noweda/plugins/outliers.py +17 -0
- noweda/plugins/pii.py +18 -0
- noweda/plugins/schema.py +68 -0
- noweda/plugins/stats.py +40 -0
- noweda/report/__init__.py +0 -0
- noweda/report/html.py +283 -0
- noweda/scoring/__init__.py +0 -0
- noweda/scoring/scorer.py +86 -0
- noweda-0.1.0.dist-info/METADATA +248 -0
- noweda-0.1.0.dist-info/RECORD +28 -0
- noweda-0.1.0.dist-info/WHEEL +5 -0
- noweda-0.1.0.dist-info/entry_points.txt +2 -0
- noweda-0.1.0.dist-info/licenses/LICENSE +21 -0
- noweda-0.1.0.dist-info/top_level.txt +1 -0
noweda/__init__.py
ADDED
noweda/accessor.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from noweda.core.engine import AutoEDAEngine
|
|
3
|
+
from noweda.plugins import default_plugins
|
|
4
|
+
|
|
5
|
+
@pd.api.extensions.register_dataframe_accessor("noweda")
|
|
6
|
+
class NowEDAAccessor:
|
|
7
|
+
|
|
8
|
+
def __init__(self, pandas_obj):
|
|
9
|
+
self._df = pandas_obj
|
|
10
|
+
self._report = None
|
|
11
|
+
|
|
12
|
+
def _ensure_analyzed(self):
|
|
13
|
+
if self._report is None:
|
|
14
|
+
engine = AutoEDAEngine(default_plugins())
|
|
15
|
+
self._report = engine.run_df(self._df)
|
|
16
|
+
|
|
17
|
+
def summary(self):
|
|
18
|
+
self._ensure_analyzed()
|
|
19
|
+
return self._report["results"]
|
|
20
|
+
|
|
21
|
+
def insights(self):
|
|
22
|
+
self._ensure_analyzed()
|
|
23
|
+
return self._report["insights"]
|
|
24
|
+
|
|
25
|
+
def score(self):
|
|
26
|
+
self._ensure_analyzed()
|
|
27
|
+
return self._report["scores"]
|
|
28
|
+
|
|
29
|
+
def report(self):
|
|
30
|
+
self._ensure_analyzed()
|
|
31
|
+
return self._report
|
noweda/cli.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from noweda import read
|
|
4
|
+
from noweda.report.html import generate_html_report
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(description="NowEDA CLI")
|
|
8
|
+
parser.add_argument("file", help="Path to dataset")
|
|
9
|
+
parser.add_argument("--html", help="Export HTML report", default=None)
|
|
10
|
+
parser.add_argument("--json", help="Export JSON report", default=None)
|
|
11
|
+
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
|
|
14
|
+
df = read(args.file)
|
|
15
|
+
report = df.noweda.report()
|
|
16
|
+
|
|
17
|
+
print("\n=== NOWEDA INSIGHTS ===")
|
|
18
|
+
for insight in report["insights"]:
|
|
19
|
+
print(f"- {insight}")
|
|
20
|
+
|
|
21
|
+
print("\n=== SCORES ===")
|
|
22
|
+
print(report["scores"])
|
|
23
|
+
|
|
24
|
+
if args.json:
|
|
25
|
+
with open(args.json, "w") as f:
|
|
26
|
+
json.dump(report, f, indent=2)
|
|
27
|
+
|
|
28
|
+
if args.html:
|
|
29
|
+
generate_html_report(report, args.html)
|
noweda/core/__init__.py
ADDED
|
File without changes
|
noweda/core/engine.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from noweda.scoring.scorer import Scorer
|
|
2
|
+
from noweda.insights.generator import InsightGenerator
|
|
3
|
+
|
|
4
|
+
class AutoEDAEngine:
|
|
5
|
+
|
|
6
|
+
def __init__(self, plugins):
|
|
7
|
+
self.plugins = plugins
|
|
8
|
+
|
|
9
|
+
def run_df(self, df):
|
|
10
|
+
results = {}
|
|
11
|
+
|
|
12
|
+
for plugin in self.plugins:
|
|
13
|
+
results[plugin.name] = plugin.run(df)
|
|
14
|
+
|
|
15
|
+
scores = Scorer().compute(results)
|
|
16
|
+
insights = InsightGenerator().generate(results, scores)
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
"results": results,
|
|
20
|
+
"scores": scores,
|
|
21
|
+
"insights": insights
|
|
22
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
class InsightGenerator:
|
|
2
|
+
"""Translate raw plugin results into human-readable, actionable insights."""
|
|
3
|
+
|
|
4
|
+
def generate(self, results, scores):
|
|
5
|
+
insights = []
|
|
6
|
+
|
|
7
|
+
self._schema_insights(results.get("schema", {}), insights)
|
|
8
|
+
self._missing_insights(results.get("missing", {}), insights)
|
|
9
|
+
self._duplicates_insights(results.get("duplicates", {}), insights)
|
|
10
|
+
self._stats_insights(results.get("stats", {}), insights)
|
|
11
|
+
self._outlier_insights(results.get("outliers", {}), insights)
|
|
12
|
+
self._correlation_insights(results.get("correlation", {}), insights)
|
|
13
|
+
self._pii_insights(results.get("pii", {}), insights)
|
|
14
|
+
self._encoding_insights(results.get("encoding", {}), insights)
|
|
15
|
+
self._score_insights(scores, insights)
|
|
16
|
+
|
|
17
|
+
return insights
|
|
18
|
+
|
|
19
|
+
# ------------------------------------------------------------------
|
|
20
|
+
# Per-plugin insight rules
|
|
21
|
+
# ------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _schema_insights(self, schema, insights):
|
|
24
|
+
id_cols = [c for c, v in schema.items() if v.get("role") == "id_candidate"]
|
|
25
|
+
cat_cols = [c for c, v in schema.items() if "categorical" in v.get("role", "")]
|
|
26
|
+
datetime_cols = [c for c, v in schema.items() if v.get("role") == "datetime"]
|
|
27
|
+
|
|
28
|
+
if id_cols:
|
|
29
|
+
insights.append(
|
|
30
|
+
f"Likely identifier column(s) detected: {', '.join(id_cols)}. "
|
|
31
|
+
"Consider excluding from modelling."
|
|
32
|
+
)
|
|
33
|
+
if cat_cols:
|
|
34
|
+
insights.append(
|
|
35
|
+
f"Column(s) with low cardinality (likely categorical): {', '.join(cat_cols)}."
|
|
36
|
+
)
|
|
37
|
+
if datetime_cols:
|
|
38
|
+
insights.append(
|
|
39
|
+
f"Datetime column(s) detected: {', '.join(datetime_cols)}. "
|
|
40
|
+
"Temporal features may be valuable."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _missing_insights(self, missing, insights):
|
|
44
|
+
critical = [(c, v) for c, v in missing.items() if v > 0.5]
|
|
45
|
+
high = [(c, v) for c, v in missing.items() if 0.3 < v <= 0.5]
|
|
46
|
+
low = [(c, v) for c, v in missing.items() if 0 < v <= 0.3]
|
|
47
|
+
|
|
48
|
+
for col, pct in critical:
|
|
49
|
+
insights.append(
|
|
50
|
+
f"Column '{col}' is {pct:.0%} missing — consider dropping it."
|
|
51
|
+
)
|
|
52
|
+
for col, pct in high:
|
|
53
|
+
insights.append(
|
|
54
|
+
f"Column '{col}' has high missing rate ({pct:.0%}) — imputation recommended."
|
|
55
|
+
)
|
|
56
|
+
if low:
|
|
57
|
+
cols = ", ".join(f"'{c}'" for c, _ in low)
|
|
58
|
+
insights.append(f"Minor missing values in: {cols}.")
|
|
59
|
+
|
|
60
|
+
def _duplicates_insights(self, duplicates, insights):
|
|
61
|
+
dup_rows = duplicates.get("duplicate_rows", 0)
|
|
62
|
+
dup_pct = duplicates.get("duplicate_rows_pct", 0.0)
|
|
63
|
+
const_cols = duplicates.get("constant_columns", [])
|
|
64
|
+
|
|
65
|
+
if dup_rows > 0:
|
|
66
|
+
insights.append(
|
|
67
|
+
f"{dup_rows} duplicate row(s) detected ({dup_pct:.1%} of data). "
|
|
68
|
+
"De-duplication is recommended."
|
|
69
|
+
)
|
|
70
|
+
if const_cols:
|
|
71
|
+
insights.append(
|
|
72
|
+
f"Constant (zero-variance) column(s) detected: {', '.join(const_cols)}. "
|
|
73
|
+
"These carry no information and can be dropped."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _stats_insights(self, stats, insights):
|
|
77
|
+
for col, s in stats.items():
|
|
78
|
+
skew = s.get("skewness")
|
|
79
|
+
if skew is not None and abs(skew) > 2:
|
|
80
|
+
direction = "right" if skew > 0 else "left"
|
|
81
|
+
insights.append(
|
|
82
|
+
f"Column '{col}' is heavily {direction}-skewed (skewness={skew:.2f}). "
|
|
83
|
+
"Log or power transform may help."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _outlier_insights(self, outliers, insights):
|
|
87
|
+
for col, count in outliers.items():
|
|
88
|
+
if count > 0:
|
|
89
|
+
insights.append(
|
|
90
|
+
f"Column '{col}' has {count} outlier(s) (IQR method). "
|
|
91
|
+
"Review before modelling."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _correlation_insights(self, correlation, insights):
|
|
95
|
+
seen = set()
|
|
96
|
+
for col1, corrs in correlation.items():
|
|
97
|
+
for col2, val in corrs.items():
|
|
98
|
+
pair = tuple(sorted([col1, col2]))
|
|
99
|
+
if col1 == col2 or pair in seen:
|
|
100
|
+
continue
|
|
101
|
+
seen.add(pair)
|
|
102
|
+
if abs(val) > 0.9:
|
|
103
|
+
insights.append(
|
|
104
|
+
f"Very strong correlation ({val:.2f}) between '{col1}' and '{col2}'. "
|
|
105
|
+
"One may be redundant."
|
|
106
|
+
)
|
|
107
|
+
elif abs(val) > 0.7:
|
|
108
|
+
insights.append(
|
|
109
|
+
f"Strong correlation ({val:.2f}) between '{col1}' and '{col2}'."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _pii_insights(self, pii, insights):
|
|
113
|
+
for col, info in pii.items():
|
|
114
|
+
count = info.get("emails_detected", 0)
|
|
115
|
+
insights.append(
|
|
116
|
+
f"PII detected in column '{col}': {count} email address(es) found. "
|
|
117
|
+
"Mask or remove before sharing."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _encoding_insights(self, encoding, insights):
|
|
121
|
+
for col, enc_type in encoding.items():
|
|
122
|
+
insights.append(
|
|
123
|
+
f"Column '{col}' may contain encoded data ({enc_type}). "
|
|
124
|
+
"Inspect for hidden payloads or obfuscation."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _score_insights(self, scores, insights):
|
|
128
|
+
quality = scores.get("data_quality", 100)
|
|
129
|
+
risk = scores.get("risk", 0)
|
|
130
|
+
|
|
131
|
+
if quality >= 90:
|
|
132
|
+
insights.append("Data quality score is excellent (>=90). Dataset looks clean.")
|
|
133
|
+
elif quality >= 70:
|
|
134
|
+
insights.append(f"Data quality score is acceptable ({quality}). Minor issues present.")
|
|
135
|
+
else:
|
|
136
|
+
insights.append(
|
|
137
|
+
f"Data quality score is low ({quality}). Significant cleaning required."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if risk == 0:
|
|
141
|
+
insights.append("No risk signals detected.")
|
|
142
|
+
elif risk <= 20:
|
|
143
|
+
insights.append(f"Low risk level ({risk}). Some sensitive indicators found.")
|
|
144
|
+
elif risk <= 50:
|
|
145
|
+
insights.append(
|
|
146
|
+
f"Moderate risk level ({risk}). Review PII and encoded columns before sharing."
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
insights.append(
|
|
150
|
+
f"High risk level ({risk}). Sensitive data likely present — handle with care."
|
|
151
|
+
)
|
noweda/io.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
def read(file_path, **kwargs):
|
|
4
|
+
if file_path.endswith(".csv"):
|
|
5
|
+
return pd.read_csv(file_path, **kwargs)
|
|
6
|
+
elif file_path.endswith(".xlsx"):
|
|
7
|
+
return pd.read_excel(file_path, **kwargs)
|
|
8
|
+
elif file_path.endswith(".json"):
|
|
9
|
+
return pd.read_json(file_path, **kwargs)
|
|
10
|
+
elif file_path.endswith(".xml"):
|
|
11
|
+
return pd.read_xml(file_path, **kwargs)
|
|
12
|
+
elif file_path.endswith(".html"):
|
|
13
|
+
return pd.read_html(file_path, **kwargs)[0]
|
|
14
|
+
else:
|
|
15
|
+
raise ValueError(f"Unsupported file type: {file_path}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .missing import MissingDataPlugin
|
|
2
|
+
from .stats import StatsPlugin
|
|
3
|
+
from .schema import SchemaPlugin
|
|
4
|
+
from .duplicates import DuplicatesPlugin
|
|
5
|
+
from .correlation import CorrelationPlugin
|
|
6
|
+
from .outliers import OutlierPlugin
|
|
7
|
+
from .pii import PIIDetectorPlugin
|
|
8
|
+
from .encoding import EncodingDetectionPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_plugins():
|
|
12
|
+
return [
|
|
13
|
+
SchemaPlugin(),
|
|
14
|
+
StatsPlugin(),
|
|
15
|
+
MissingDataPlugin(),
|
|
16
|
+
DuplicatesPlugin(),
|
|
17
|
+
CorrelationPlugin(),
|
|
18
|
+
OutlierPlugin(),
|
|
19
|
+
PIIDetectorPlugin(),
|
|
20
|
+
EncodingDetectionPlugin(),
|
|
21
|
+
]
|
noweda/plugins/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .base import BasePlugin
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DuplicatesPlugin(BasePlugin):
|
|
5
|
+
"""Detect duplicate rows and constant (zero-variance) columns."""
|
|
6
|
+
|
|
7
|
+
name = "duplicates"
|
|
8
|
+
|
|
9
|
+
def run(self, df):
|
|
10
|
+
n_rows = len(df)
|
|
11
|
+
|
|
12
|
+
duplicate_rows = int(df.duplicated().sum())
|
|
13
|
+
duplicate_pct = round(duplicate_rows / n_rows, 4) if n_rows > 0 else 0.0
|
|
14
|
+
|
|
15
|
+
constant_columns = [
|
|
16
|
+
col for col in df.columns if df[col].nunique(dropna=False) <= 1
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
"duplicate_rows": duplicate_rows,
|
|
21
|
+
"duplicate_rows_pct": duplicate_pct,
|
|
22
|
+
"constant_columns": constant_columns,
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from .base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class EncodingDetectionPlugin(BasePlugin):
|
|
5
|
+
name = "encoding"
|
|
6
|
+
|
|
7
|
+
def is_base64(self, s):
|
|
8
|
+
try:
|
|
9
|
+
return base64.b64encode(base64.b64decode(s)) == s.encode()
|
|
10
|
+
except:
|
|
11
|
+
return False
|
|
12
|
+
|
|
13
|
+
def run(self, df):
|
|
14
|
+
results = {}
|
|
15
|
+
|
|
16
|
+
for col in df.columns:
|
|
17
|
+
if df[col].dtype == "object":
|
|
18
|
+
sample = df[col].dropna().astype(str).head(20)
|
|
19
|
+
count = sum(self.is_base64(x) for x in sample)
|
|
20
|
+
|
|
21
|
+
if count > 5:
|
|
22
|
+
results[col] = "possible_base64"
|
|
23
|
+
|
|
24
|
+
return results
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .base import BasePlugin
|
|
2
|
+
|
|
3
|
+
class OutlierPlugin(BasePlugin):
|
|
4
|
+
name = "outliers"
|
|
5
|
+
|
|
6
|
+
def run(self, df):
|
|
7
|
+
result = {}
|
|
8
|
+
|
|
9
|
+
for col in df.select_dtypes(include="number").columns:
|
|
10
|
+
q1 = df[col].quantile(0.25)
|
|
11
|
+
q3 = df[col].quantile(0.75)
|
|
12
|
+
iqr = q3 - q1
|
|
13
|
+
|
|
14
|
+
outliers = df[(df[col] < q1 - 1.5*iqr) | (df[col] > q3 + 1.5*iqr)]
|
|
15
|
+
result[col] = int(len(outliers))
|
|
16
|
+
|
|
17
|
+
return result
|
noweda/plugins/pii.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from .base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class PIIDetectorPlugin(BasePlugin):
|
|
5
|
+
name = "pii"
|
|
6
|
+
|
|
7
|
+
EMAIL_REGEX = r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"
|
|
8
|
+
|
|
9
|
+
def run(self, df):
|
|
10
|
+
findings = {}
|
|
11
|
+
|
|
12
|
+
for col in df.columns:
|
|
13
|
+
if df[col].dtype == "object":
|
|
14
|
+
matches = df[col].astype(str).str.contains(self.EMAIL_REGEX, regex=True).sum()
|
|
15
|
+
if matches > 0:
|
|
16
|
+
findings[col] = {"emails_detected": int(matches)}
|
|
17
|
+
|
|
18
|
+
return findings
|
noweda/plugins/schema.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from .base import BasePlugin
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Cardinality thresholds
|
|
5
|
+
_ID_UNIQUENESS = 0.95 # column is likely an ID/key
|
|
6
|
+
_CAT_CARDINALITY = 0.05 # column is likely categorical
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SchemaPlugin(BasePlugin):
|
|
10
|
+
"""Infer semantic column roles: id, categorical, numeric, datetime, text."""
|
|
11
|
+
|
|
12
|
+
name = "schema"
|
|
13
|
+
|
|
14
|
+
def _infer_role(self, series, n_rows):
|
|
15
|
+
dtype = series.dtype
|
|
16
|
+
n_unique = series.nunique()
|
|
17
|
+
uniqueness = n_unique / n_rows if n_rows > 0 else 0
|
|
18
|
+
|
|
19
|
+
# Datetime detection
|
|
20
|
+
if dtype.kind == "M":
|
|
21
|
+
return "datetime"
|
|
22
|
+
|
|
23
|
+
# Try to parse object columns as datetime
|
|
24
|
+
if dtype == "object":
|
|
25
|
+
sample = series.dropna().astype(str).head(20)
|
|
26
|
+
import pandas as pd
|
|
27
|
+
try:
|
|
28
|
+
pd.to_datetime(sample)
|
|
29
|
+
return "datetime"
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
# Numeric
|
|
34
|
+
if dtype.kind in ("i", "u", "f"):
|
|
35
|
+
# Only flag integer-typed columns with perfect uniqueness as IDs.
|
|
36
|
+
# Float columns (salary, score) are almost never true keys.
|
|
37
|
+
# Require uniqueness == 1.0 so numeric columns with any duplicates
|
|
38
|
+
# (like age appearing twice) are NOT incorrectly flagged.
|
|
39
|
+
if dtype.kind in ("i", "u") and uniqueness == 1.0 and n_unique > 5:
|
|
40
|
+
return "id_candidate"
|
|
41
|
+
if uniqueness <= _CAT_CARDINALITY and n_unique <= 20:
|
|
42
|
+
return "categorical_numeric"
|
|
43
|
+
return "numeric"
|
|
44
|
+
|
|
45
|
+
# Object / string
|
|
46
|
+
if dtype == "object":
|
|
47
|
+
# Require near-perfect uniqueness for string ID detection
|
|
48
|
+
if uniqueness >= 0.98 and n_unique > 5:
|
|
49
|
+
return "id_candidate"
|
|
50
|
+
if uniqueness <= _CAT_CARDINALITY and n_unique <= 50:
|
|
51
|
+
return "categorical"
|
|
52
|
+
return "text"
|
|
53
|
+
|
|
54
|
+
return "unknown"
|
|
55
|
+
|
|
56
|
+
def run(self, df):
|
|
57
|
+
n_rows = len(df)
|
|
58
|
+
result = {}
|
|
59
|
+
|
|
60
|
+
for col in df.columns:
|
|
61
|
+
result[col] = {
|
|
62
|
+
"dtype": str(df[col].dtype),
|
|
63
|
+
"role": self._infer_role(df[col], n_rows),
|
|
64
|
+
"unique": int(df[col].nunique()),
|
|
65
|
+
"uniqueness_ratio": round(df[col].nunique() / n_rows, 4) if n_rows > 0 else 0,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result
|
noweda/plugins/stats.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from .base import BasePlugin
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StatsPlugin(BasePlugin):
|
|
5
|
+
"""Compute descriptive statistics for numeric and categorical columns."""
|
|
6
|
+
|
|
7
|
+
name = "stats"
|
|
8
|
+
|
|
9
|
+
def run(self, df):
|
|
10
|
+
result = {}
|
|
11
|
+
|
|
12
|
+
for col in df.columns:
|
|
13
|
+
series = df[col]
|
|
14
|
+
col_stats = {
|
|
15
|
+
"dtype": str(series.dtype),
|
|
16
|
+
"count": int(series.count()),
|
|
17
|
+
"missing": int(series.isna().sum()),
|
|
18
|
+
"unique": int(series.nunique()),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if series.dtype.kind in ("i", "u", "f"):
|
|
22
|
+
col_stats.update({
|
|
23
|
+
"mean": float(series.mean()),
|
|
24
|
+
"median": float(series.median()),
|
|
25
|
+
"std": float(series.std()),
|
|
26
|
+
"min": float(series.min()),
|
|
27
|
+
"max": float(series.max()),
|
|
28
|
+
"q25": float(series.quantile(0.25)),
|
|
29
|
+
"q75": float(series.quantile(0.75)),
|
|
30
|
+
"skewness": float(series.skew()),
|
|
31
|
+
})
|
|
32
|
+
elif series.dtype == "object" or str(series.dtype) == "category":
|
|
33
|
+
top = series.value_counts()
|
|
34
|
+
if not top.empty:
|
|
35
|
+
col_stats["top_value"] = str(top.index[0])
|
|
36
|
+
col_stats["top_freq"] = int(top.iloc[0])
|
|
37
|
+
|
|
38
|
+
result[col] = col_stats
|
|
39
|
+
|
|
40
|
+
return result
|
|
File without changes
|
noweda/report/html.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generate_html_report(report, output_path):
|
|
6
|
+
scores = report.get("scores", {})
|
|
7
|
+
insights = report.get("insights", [])
|
|
8
|
+
results = report.get("results", {})
|
|
9
|
+
|
|
10
|
+
quality = scores.get("data_quality", 0)
|
|
11
|
+
risk = scores.get("risk", 0)
|
|
12
|
+
readiness = scores.get("model_readiness", 0)
|
|
13
|
+
|
|
14
|
+
quality_color = _score_color(quality, invert=False)
|
|
15
|
+
risk_color = _score_color(risk, invert=True, max_val=100)
|
|
16
|
+
readiness_color = _score_color(readiness, invert=False)
|
|
17
|
+
|
|
18
|
+
insights_html = "".join(
|
|
19
|
+
f'<li class="insight-item">{_escape(i)}</li>' for i in insights
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
schema_html = _schema_table(results.get("schema", {}))
|
|
23
|
+
missing_html = _missing_table(results.get("missing", {}))
|
|
24
|
+
duplicates_html = _duplicates_section(results.get("duplicates", {}))
|
|
25
|
+
outliers_html = _outliers_table(results.get("outliers", {}))
|
|
26
|
+
pii_html = _pii_table(results.get("pii", {}))
|
|
27
|
+
encoding_html = _encoding_table(results.get("encoding", {}))
|
|
28
|
+
|
|
29
|
+
generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
30
|
+
|
|
31
|
+
html = f"""<!DOCTYPE html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="UTF-8" />
|
|
35
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
36
|
+
<title>NowEDA Report</title>
|
|
37
|
+
<style>
|
|
38
|
+
:root {{
|
|
39
|
+
--bg: #0f1117;
|
|
40
|
+
--surface: #1a1d27;
|
|
41
|
+
--surface2: #22263a;
|
|
42
|
+
--border: #2e3250;
|
|
43
|
+
--text: #e2e8f0;
|
|
44
|
+
--muted: #8892b0;
|
|
45
|
+
--accent: #7c6af7;
|
|
46
|
+
--green: #4ade80;
|
|
47
|
+
--yellow: #facc15;
|
|
48
|
+
--red: #f87171;
|
|
49
|
+
}}
|
|
50
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
51
|
+
body {{ background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; }}
|
|
52
|
+
.container {{ max-width: 1100px; margin: 0 auto; padding: 32px 24px; }}
|
|
53
|
+
.header {{ margin-bottom: 36px; border-bottom: 1px solid var(--border); padding-bottom: 20px; }}
|
|
54
|
+
.header h1 {{ font-size: 28px; font-weight: 700; color: var(--accent); letter-spacing: -0.5px; }}
|
|
55
|
+
.header .meta {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
|
|
56
|
+
.scores-grid {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; }}
|
|
57
|
+
.score-card {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; text-align: center; }}
|
|
58
|
+
.score-card .label {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }}
|
|
59
|
+
.score-card .value {{ font-size: 40px; font-weight: 800; line-height: 1; }}
|
|
60
|
+
.score-card .sub {{ font-size: 11px; color: var(--muted); margin-top: 4px; }}
|
|
61
|
+
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 24px; }}
|
|
62
|
+
.section h2 {{ font-size: 16px; font-weight: 600; margin-bottom: 16px; color: var(--text); border-bottom: 1px solid var(--border); padding-bottom: 10px; }}
|
|
63
|
+
.insight-list {{ list-style: none; display: flex; flex-direction: column; gap: 8px; }}
|
|
64
|
+
.insight-item {{ background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 4px; padding: 10px 14px; font-size: 13px; line-height: 1.5; }}
|
|
65
|
+
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
|
|
66
|
+
th {{ text-align: left; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 12px; border-bottom: 1px solid var(--border); }}
|
|
67
|
+
td {{ padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--text); }}
|
|
68
|
+
tr:last-child td {{ border-bottom: none; }}
|
|
69
|
+
tr:hover td {{ background: var(--surface2); }}
|
|
70
|
+
.tag {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }}
|
|
71
|
+
.tag-id {{ background: #312e81; color: #a5b4fc; }}
|
|
72
|
+
.tag-cat {{ background: #164e63; color: #67e8f9; }}
|
|
73
|
+
.tag-num {{ background: #14532d; color: #86efac; }}
|
|
74
|
+
.tag-text {{ background: #44403c; color: #d6d3d1; }}
|
|
75
|
+
.tag-dt {{ background: #4a1d96; color: #c4b5fd; }}
|
|
76
|
+
.tag-unk {{ background: #2d2d2d; color: #9ca3af; }}
|
|
77
|
+
.pct-bar {{ background: var(--surface2); border-radius: 4px; height: 6px; width: 100px; display: inline-block; vertical-align: middle; margin-left: 8px; }}
|
|
78
|
+
.pct-fill {{ height: 100%; border-radius: 4px; }}
|
|
79
|
+
.empty {{ color: var(--muted); font-style: italic; font-size: 13px; }}
|
|
80
|
+
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border); }}
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div class="container">
|
|
85
|
+
|
|
86
|
+
<div class="header">
|
|
87
|
+
<h1>NowEDA Report</h1>
|
|
88
|
+
<div class="meta">Generated: {generated_at}</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="scores-grid">
|
|
92
|
+
<div class="score-card">
|
|
93
|
+
<div class="label">Data Quality</div>
|
|
94
|
+
<div class="value" style="color:{quality_color}">{quality}</div>
|
|
95
|
+
<div class="sub">out of 100</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="score-card">
|
|
98
|
+
<div class="label">Risk Level</div>
|
|
99
|
+
<div class="value" style="color:{risk_color}">{risk}</div>
|
|
100
|
+
<div class="sub">higher = more risk</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="score-card">
|
|
103
|
+
<div class="label">Model Readiness</div>
|
|
104
|
+
<div class="value" style="color:{readiness_color}">{readiness}</div>
|
|
105
|
+
<div class="sub">out of 100</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="section">
|
|
110
|
+
<h2>Insights</h2>
|
|
111
|
+
{"<ul class='insight-list'>" + insights_html + "</ul>" if insights else "<p class='empty'>No insights generated.</p>"}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{schema_html}
|
|
115
|
+
{missing_html}
|
|
116
|
+
{duplicates_html}
|
|
117
|
+
{outliers_html}
|
|
118
|
+
{pii_html}
|
|
119
|
+
{encoding_html}
|
|
120
|
+
|
|
121
|
+
<div class="footer">
|
|
122
|
+
Built with <strong>NowEDA</strong> — Automated EDA Framework
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
</div>
|
|
126
|
+
</body>
|
|
127
|
+
</html>"""
|
|
128
|
+
|
|
129
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
130
|
+
f.write(html)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
# Helpers
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _escape(s):
|
|
138
|
+
return (
|
|
139
|
+
str(s)
|
|
140
|
+
.replace("&", "&")
|
|
141
|
+
.replace("<", "<")
|
|
142
|
+
.replace(">", ">")
|
|
143
|
+
.replace('"', """)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _score_color(val, invert=False, max_val=100):
|
|
148
|
+
"""Return a CSS color (green/yellow/red) based on value."""
|
|
149
|
+
ratio = val / max_val if max_val else 0
|
|
150
|
+
if invert:
|
|
151
|
+
ratio = 1 - min(ratio, 1)
|
|
152
|
+
if ratio >= 0.75:
|
|
153
|
+
return "var(--green)"
|
|
154
|
+
elif ratio >= 0.4:
|
|
155
|
+
return "var(--yellow)"
|
|
156
|
+
return "var(--red)"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _role_tag(role):
|
|
160
|
+
mapping = {
|
|
161
|
+
"id_candidate": ("ID", "tag-id"),
|
|
162
|
+
"categorical": ("Categorical", "tag-cat"),
|
|
163
|
+
"categorical_numeric": ("Cat. Numeric", "tag-cat"),
|
|
164
|
+
"numeric": ("Numeric", "tag-num"),
|
|
165
|
+
"text": ("Text", "tag-text"),
|
|
166
|
+
"datetime": ("Datetime", "tag-dt"),
|
|
167
|
+
"unknown": ("Unknown", "tag-unk"),
|
|
168
|
+
}
|
|
169
|
+
label, css = mapping.get(role, (role, "tag-unk"))
|
|
170
|
+
return f'<span class="tag {css}">{label}</span>'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _schema_table(schema):
|
|
174
|
+
if not schema:
|
|
175
|
+
return ""
|
|
176
|
+
rows = ""
|
|
177
|
+
for col, info in schema.items():
|
|
178
|
+
rows += (
|
|
179
|
+
f"<tr>"
|
|
180
|
+
f"<td>{_escape(col)}</td>"
|
|
181
|
+
f"<td>{_escape(info.get('dtype', ''))}</td>"
|
|
182
|
+
f"<td>{_role_tag(info.get('role', 'unknown'))}</td>"
|
|
183
|
+
f"<td>{info.get('unique', '')}</td>"
|
|
184
|
+
f"<td>{info.get('uniqueness_ratio', ''):.2%}</td>"
|
|
185
|
+
f"</tr>"
|
|
186
|
+
)
|
|
187
|
+
return f"""<div class="section">
|
|
188
|
+
<h2>Column Schema</h2>
|
|
189
|
+
<table>
|
|
190
|
+
<thead><tr><th>Column</th><th>Dtype</th><th>Role</th><th>Unique Values</th><th>Uniqueness</th></tr></thead>
|
|
191
|
+
<tbody>{rows}</tbody>
|
|
192
|
+
</table>
|
|
193
|
+
</div>"""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _missing_table(missing):
|
|
197
|
+
if not missing:
|
|
198
|
+
return ""
|
|
199
|
+
rows = ""
|
|
200
|
+
for col, pct in sorted(missing.items(), key=lambda x: -x[1]):
|
|
201
|
+
pct_int = int(pct * 100)
|
|
202
|
+
bar_color = "#f87171" if pct > 0.3 else ("#facc15" if pct > 0 else "#4ade80")
|
|
203
|
+
bar = (
|
|
204
|
+
f'<span class="pct-bar"><span class="pct-fill" '
|
|
205
|
+
f'style="width:{pct_int}%;background:{bar_color}"></span></span>'
|
|
206
|
+
)
|
|
207
|
+
rows += f"<tr><td>{_escape(col)}</td><td>{pct:.1%} {bar}</td></tr>"
|
|
208
|
+
return f"""<div class="section">
|
|
209
|
+
<h2>Missing Values</h2>
|
|
210
|
+
<table>
|
|
211
|
+
<thead><tr><th>Column</th><th>Missing %</th></tr></thead>
|
|
212
|
+
<tbody>{rows}</tbody>
|
|
213
|
+
</table>
|
|
214
|
+
</div>"""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _duplicates_section(duplicates):
|
|
218
|
+
if not duplicates:
|
|
219
|
+
return ""
|
|
220
|
+
dup_rows = duplicates.get("duplicate_rows", 0)
|
|
221
|
+
dup_pct = duplicates.get("duplicate_rows_pct", 0.0)
|
|
222
|
+
const_cols = duplicates.get("constant_columns", [])
|
|
223
|
+
const_str = ", ".join(const_cols) if const_cols else "None"
|
|
224
|
+
return f"""<div class="section">
|
|
225
|
+
<h2>Duplicates & Constants</h2>
|
|
226
|
+
<table>
|
|
227
|
+
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
|
228
|
+
<tbody>
|
|
229
|
+
<tr><td>Duplicate rows</td><td>{dup_rows} ({dup_pct:.1%})</td></tr>
|
|
230
|
+
<tr><td>Constant columns</td><td>{_escape(const_str)}</td></tr>
|
|
231
|
+
</tbody>
|
|
232
|
+
</table>
|
|
233
|
+
</div>"""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _outliers_table(outliers):
|
|
237
|
+
if not outliers:
|
|
238
|
+
return ""
|
|
239
|
+
rows = "".join(
|
|
240
|
+
f"<tr><td>{_escape(col)}</td><td>{count}</td></tr>"
|
|
241
|
+
for col, count in sorted(outliers.items(), key=lambda x: -x[1])
|
|
242
|
+
if count > 0
|
|
243
|
+
)
|
|
244
|
+
if not rows:
|
|
245
|
+
return ""
|
|
246
|
+
return f"""<div class="section">
|
|
247
|
+
<h2>Outliers (IQR Method)</h2>
|
|
248
|
+
<table>
|
|
249
|
+
<thead><tr><th>Column</th><th>Outlier Count</th></tr></thead>
|
|
250
|
+
<tbody>{rows}</tbody>
|
|
251
|
+
</table>
|
|
252
|
+
</div>"""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _pii_table(pii):
|
|
256
|
+
if not pii:
|
|
257
|
+
return ""
|
|
258
|
+
rows = ""
|
|
259
|
+
for col, info in pii.items():
|
|
260
|
+
rows += f"<tr><td>{_escape(col)}</td><td>{_escape(str(info))}</td></tr>"
|
|
261
|
+
return f"""<div class="section" style="border-color:#f87171">
|
|
262
|
+
<h2>PII Detection</h2>
|
|
263
|
+
<table>
|
|
264
|
+
<thead><tr><th>Column</th><th>Finding</th></tr></thead>
|
|
265
|
+
<tbody>{rows}</tbody>
|
|
266
|
+
</table>
|
|
267
|
+
</div>"""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _encoding_table(encoding):
|
|
271
|
+
if not encoding:
|
|
272
|
+
return ""
|
|
273
|
+
rows = "".join(
|
|
274
|
+
f"<tr><td>{_escape(col)}</td><td>{_escape(enc_type)}</td></tr>"
|
|
275
|
+
for col, enc_type in encoding.items()
|
|
276
|
+
)
|
|
277
|
+
return f"""<div class="section" style="border-color:#facc15">
|
|
278
|
+
<h2>Encoding Detection</h2>
|
|
279
|
+
<table>
|
|
280
|
+
<thead><tr><th>Column</th><th>Detected Encoding</th></tr></thead>
|
|
281
|
+
<tbody>{rows}</tbody>
|
|
282
|
+
</table>
|
|
283
|
+
</div>"""
|
|
File without changes
|
noweda/scoring/scorer.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
class Scorer:
|
|
2
|
+
"""
|
|
3
|
+
Compute data quality and risk scores from plugin results.
|
|
4
|
+
|
|
5
|
+
Scores:
|
|
6
|
+
data_quality : 0-100 (higher = cleaner)
|
|
7
|
+
risk : 0+ (higher = more sensitive/risky)
|
|
8
|
+
model_readiness : 0-100 (higher = more ready for ML)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def compute(self, results):
|
|
12
|
+
scores = {
|
|
13
|
+
"data_quality": 100,
|
|
14
|
+
"risk": 0,
|
|
15
|
+
"model_readiness": 100,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
self._penalise_missing(results.get("missing", {}), scores)
|
|
19
|
+
self._penalise_duplicates(results.get("duplicates", {}), scores)
|
|
20
|
+
self._penalise_outliers(results.get("outliers", {}), scores)
|
|
21
|
+
self._penalise_skew(results.get("stats", {}), scores)
|
|
22
|
+
self._add_pii_risk(results.get("pii", {}), scores)
|
|
23
|
+
self._add_encoding_risk(results.get("encoding", {}), scores)
|
|
24
|
+
self._penalise_schema(results.get("schema", {}), scores)
|
|
25
|
+
|
|
26
|
+
# Clamp quality and readiness to [0, 100]
|
|
27
|
+
scores["data_quality"] = max(0, min(100, scores["data_quality"]))
|
|
28
|
+
scores["model_readiness"] = max(0, min(100, scores["model_readiness"]))
|
|
29
|
+
|
|
30
|
+
return scores
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _penalise_missing(self, missing, scores):
|
|
35
|
+
for pct in missing.values():
|
|
36
|
+
if pct > 0.5:
|
|
37
|
+
scores["data_quality"] -= 10
|
|
38
|
+
scores["model_readiness"] -= 15
|
|
39
|
+
elif pct > 0.3:
|
|
40
|
+
scores["data_quality"] -= 5
|
|
41
|
+
scores["model_readiness"] -= 8
|
|
42
|
+
elif pct > 0:
|
|
43
|
+
scores["data_quality"] -= 2
|
|
44
|
+
scores["model_readiness"] -= 3
|
|
45
|
+
|
|
46
|
+
def _penalise_duplicates(self, duplicates, scores):
|
|
47
|
+
dup_pct = duplicates.get("duplicate_rows_pct", 0.0)
|
|
48
|
+
const_cols = duplicates.get("constant_columns", [])
|
|
49
|
+
|
|
50
|
+
if dup_pct > 0.1:
|
|
51
|
+
scores["data_quality"] -= 10
|
|
52
|
+
elif dup_pct > 0:
|
|
53
|
+
scores["data_quality"] -= 3
|
|
54
|
+
|
|
55
|
+
scores["data_quality"] -= len(const_cols) * 3
|
|
56
|
+
scores["model_readiness"] -= len(const_cols) * 5
|
|
57
|
+
|
|
58
|
+
def _penalise_outliers(self, outliers, scores):
|
|
59
|
+
total = sum(outliers.values())
|
|
60
|
+
if total > 50:
|
|
61
|
+
scores["data_quality"] -= 10
|
|
62
|
+
scores["model_readiness"] -= 10
|
|
63
|
+
elif total > 10:
|
|
64
|
+
scores["data_quality"] -= 5
|
|
65
|
+
scores["model_readiness"] -= 5
|
|
66
|
+
|
|
67
|
+
def _penalise_skew(self, stats, scores):
|
|
68
|
+
heavy_skew_cols = [
|
|
69
|
+
col for col, s in stats.items()
|
|
70
|
+
if s.get("skewness") is not None and abs(s["skewness"]) > 2
|
|
71
|
+
]
|
|
72
|
+
scores["model_readiness"] -= len(heavy_skew_cols) * 5
|
|
73
|
+
|
|
74
|
+
def _add_pii_risk(self, pii, scores):
|
|
75
|
+
scores["risk"] += len(pii) * 15
|
|
76
|
+
|
|
77
|
+
def _add_encoding_risk(self, encoding, scores):
|
|
78
|
+
scores["risk"] += len(encoding) * 10
|
|
79
|
+
|
|
80
|
+
def _penalise_schema(self, schema, scores):
|
|
81
|
+
# All-text or all-unknown columns reduce model readiness
|
|
82
|
+
untyped = sum(
|
|
83
|
+
1 for v in schema.values()
|
|
84
|
+
if v.get("role") in ("text", "unknown")
|
|
85
|
+
)
|
|
86
|
+
scores["model_readiness"] -= untyped * 3
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: noweda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automated EDA with insights, scoring, and security-aware detection — built as a native pandas extension.
|
|
5
|
+
Author-email: Daniel Peng <danielpeng95@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/codewithdaniel1/NowEDA
|
|
8
|
+
Project-URL: Repository, https://github.com/codewithdaniel1/NowEDA
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/codewithdaniel1/NowEDA/issues
|
|
10
|
+
Keywords: eda,data-analysis,pandas,data-quality,pii-detection,automated-eda,profiling
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: pandas>=1.3
|
|
26
|
+
Requires-Dist: numpy>=1.21
|
|
27
|
+
Requires-Dist: openpyxl>=3.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# NowEDA
|
|
31
|
+
|
|
32
|
+
**Automated Exploratory Data Analysis — built as a native pandas extension.**
|
|
33
|
+
|
|
34
|
+
NowEDA is a lightweight, modular Python framework that turns any dataset into instant insight. Load any file, call `df.noweda.*`, and get a full EDA report — including data quality scoring, PII detection, outlier analysis, and human-readable insights — with zero boilerplate.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
| Feature | Description |
|
|
41
|
+
|---|---|
|
|
42
|
+
| Universal ingestion | CSV, Excel, JSON, XML, HTML |
|
|
43
|
+
| Native pandas accessor | `df.noweda.*` — feels like pandas |
|
|
44
|
+
| Plugin architecture | Every analysis is a swappable plugin |
|
|
45
|
+
| Schema inference | Auto-detects IDs, categoricals, datetimes, text |
|
|
46
|
+
| Data quality scoring | 0–100 quality + model-readiness score |
|
|
47
|
+
| Risk scoring | PII and encoding risk level |
|
|
48
|
+
| PII detection | Email addresses + extensible patterns |
|
|
49
|
+
| Encoding detection | Base64 and obfuscation signals |
|
|
50
|
+
| Outlier detection | IQR-based, per numeric column |
|
|
51
|
+
| Duplicate detection | Exact row duplicates + constant columns |
|
|
52
|
+
| Actionable insights | Human-readable text, not just numbers |
|
|
53
|
+
| HTML report | Dark-themed, stakeholder-ready export |
|
|
54
|
+
| CLI | One-liner from the terminal |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cd NowEDA
|
|
62
|
+
pip install -e .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> Requires Python 3.8+ and pandas 1.3+.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import noweda
|
|
73
|
+
|
|
74
|
+
df = noweda.read("data.csv")
|
|
75
|
+
|
|
76
|
+
# Still a regular pandas DataFrame — nothing changes
|
|
77
|
+
print(df.head())
|
|
78
|
+
print(df.describe())
|
|
79
|
+
|
|
80
|
+
# NowEDA layer
|
|
81
|
+
print(df.noweda.insights()) # human-readable insight list
|
|
82
|
+
print(df.noweda.score()) # quality, risk, model_readiness
|
|
83
|
+
print(df.noweda.summary()) # raw plugin results
|
|
84
|
+
report = df.noweda.report() # full structured dict
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### All supported formats
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
df = noweda.read("data.csv")
|
|
91
|
+
df = noweda.read("data.xlsx", sheet_name="Sheet1")
|
|
92
|
+
df = noweda.read("data.json")
|
|
93
|
+
df = noweda.read("data.xml")
|
|
94
|
+
df = noweda.read("data.html")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Any `**kwargs` are forwarded to the underlying pandas reader.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## CLI
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Print insights and scores to the terminal
|
|
105
|
+
noweda data.csv
|
|
106
|
+
|
|
107
|
+
# Export a dark-themed HTML report
|
|
108
|
+
noweda data.csv --html report.html
|
|
109
|
+
|
|
110
|
+
# Export a JSON report
|
|
111
|
+
noweda data.csv --json report.json
|
|
112
|
+
|
|
113
|
+
# Both at once
|
|
114
|
+
noweda data.csv --html report.html --json report.json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Score Breakdown
|
|
120
|
+
|
|
121
|
+
| Score | Range | Meaning |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `data_quality` | 0–100 | Penalised for missing values, duplicates, constants, outliers |
|
|
124
|
+
| `model_readiness` | 0–100 | Penalised for skew, untyped columns, high missingness |
|
|
125
|
+
| `risk` | 0+ | Added per PII column (+15) and encoded column (+10) |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Plugin System
|
|
130
|
+
|
|
131
|
+
Every analysis step is an independent plugin. You can swap, extend, or disable plugins.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from noweda.core.engine import AutoEDAEngine
|
|
135
|
+
from noweda.plugins.missing import MissingDataPlugin
|
|
136
|
+
from noweda.plugins.pii import PIIDetectorPlugin
|
|
137
|
+
|
|
138
|
+
# Run only the plugins you want
|
|
139
|
+
engine = AutoEDAEngine([MissingDataPlugin(), PIIDetectorPlugin()])
|
|
140
|
+
report = engine.run_df(df)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Built-in plugins
|
|
144
|
+
|
|
145
|
+
| Plugin | Name key | What it detects |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `SchemaPlugin` | `schema` | Column roles: id, categorical, numeric, datetime, text |
|
|
148
|
+
| `StatsPlugin` | `stats` | Descriptive stats: mean, median, std, skewness, etc. |
|
|
149
|
+
| `MissingDataPlugin` | `missing` | Per-column missing rate |
|
|
150
|
+
| `DuplicatesPlugin` | `duplicates` | Duplicate rows, constant columns |
|
|
151
|
+
| `CorrelationPlugin` | `correlation` | Pearson correlation matrix (numeric columns) |
|
|
152
|
+
| `OutlierPlugin` | `outliers` | IQR-based outlier counts per column |
|
|
153
|
+
| `PIIDetectorPlugin` | `pii` | Email addresses (extensible to SSN, phone, etc.) |
|
|
154
|
+
| `EncodingDetectionPlugin` | `encoding` | Base64-encoded strings |
|
|
155
|
+
|
|
156
|
+
### Writing a custom plugin
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from noweda.plugins.base import BasePlugin
|
|
160
|
+
|
|
161
|
+
class MyPlugin(BasePlugin):
|
|
162
|
+
name = "my_check"
|
|
163
|
+
|
|
164
|
+
def run(self, df):
|
|
165
|
+
# return any JSON-serialisable dict
|
|
166
|
+
return {"total_rows": len(df)}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## HTML Report
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
noweda examples/sample.csv --html report.html
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The report includes:
|
|
178
|
+
|
|
179
|
+
- Score cards (quality, risk, model readiness)
|
|
180
|
+
- Actionable insights list
|
|
181
|
+
- Column schema table with inferred roles
|
|
182
|
+
- Missing value bars
|
|
183
|
+
- Duplicate and constant column summary
|
|
184
|
+
- Outlier counts
|
|
185
|
+
- PII findings (highlighted)
|
|
186
|
+
- Encoding signals (highlighted)
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Running Tests
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install pytest
|
|
194
|
+
python -m pytest tests/ -v
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Project Structure
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
NowEDA/
|
|
203
|
+
├── noweda/
|
|
204
|
+
│ ├── __init__.py # exposes noweda.read()
|
|
205
|
+
│ ├── io.py # file ingestion (all formats)
|
|
206
|
+
│ ├── accessor.py # df.noweda.* pandas accessor
|
|
207
|
+
│ ├── core/
|
|
208
|
+
│ │ └── engine.py # orchestrates plugins → scorer → insights
|
|
209
|
+
│ ├── plugins/
|
|
210
|
+
│ │ ├── base.py
|
|
211
|
+
│ │ ├── schema.py
|
|
212
|
+
│ │ ├── stats.py
|
|
213
|
+
│ │ ├── missing.py
|
|
214
|
+
│ │ ├── duplicates.py
|
|
215
|
+
│ │ ├── correlation.py
|
|
216
|
+
│ │ ├── outliers.py
|
|
217
|
+
│ │ ├── pii.py
|
|
218
|
+
│ │ └── encoding.py
|
|
219
|
+
│ ├── scoring/
|
|
220
|
+
│ │ └── scorer.py
|
|
221
|
+
│ ├── insights/
|
|
222
|
+
│ │ └── generator.py
|
|
223
|
+
│ ├── report/
|
|
224
|
+
│ │ └── html.py
|
|
225
|
+
│ └── cli.py
|
|
226
|
+
├── examples/
|
|
227
|
+
│ └── sample.csv
|
|
228
|
+
├── tests/
|
|
229
|
+
│ └── test_basic.py
|
|
230
|
+
└── pyproject.toml
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Roadmap
|
|
236
|
+
|
|
237
|
+
- [ ] Visualization layer (histograms, correlation heatmap)
|
|
238
|
+
- [ ] Dataset fingerprinting / hash-based change detection
|
|
239
|
+
- [ ] Additional PII patterns (phone, SSN, credit card)
|
|
240
|
+
- [ ] Streaming / chunked ingestion for large files
|
|
241
|
+
- [ ] PyPI publish
|
|
242
|
+
- [ ] Web dashboard UI
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Author
|
|
247
|
+
|
|
248
|
+
**Daniel Peng** — [danielpeng@osiris.cyber.nyu.edu](mailto:)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
noweda/__init__.py,sha256=SfZISfrn1z9pCXoXIhKySrUjdUQyoRP3msU0qRa44Lg,77
|
|
2
|
+
noweda/accessor.py,sha256=zra5VAyWEfrjqd0kvP5ic15HzenJLY-i7cHqRZI9lL8,821
|
|
3
|
+
noweda/cli.py,sha256=p6MEIJXwlKXn-l7sBCwPvakVBjrwfbUTGyNkQsuG_6I,813
|
|
4
|
+
noweda/io.py,sha256=SMYi1pFcxO-JedqpeVMcq-Y08td5sMqKffHIkgie7h8,559
|
|
5
|
+
noweda/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
noweda/core/engine.py,sha256=ZxDO71rL2RUsaal4_iZEvPvy7h44W4gEmXWUAx8LLu8,549
|
|
7
|
+
noweda/insights/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
noweda/insights/generator.py,sha256=3sc4YUuxapOIeonUGxq_BZoV7jhRzwSRPscooj-SQgY,6336
|
|
9
|
+
noweda/plugins/__init__.py,sha256=M2grrY-qz8Q8btXdvCLIqOYOd5_9a8DGeh7bnx2a3qo,570
|
|
10
|
+
noweda/plugins/base.py,sha256=Iu-v-j8sm_hKiSIalCoBD1Uq5btS96i5Ed3aeOqy8-s,94
|
|
11
|
+
noweda/plugins/correlation.py,sha256=36sfsDHAoq_it-md6ySxDbjYVTQHDlYCPd98XMpBnwg,266
|
|
12
|
+
noweda/plugins/duplicates.py,sha256=7yjvYLjYPEJEQ5UFdgkYbtj3ul4uFaloZjS4vg_jFU4,634
|
|
13
|
+
noweda/plugins/encoding.py,sha256=BlN0kn02ZRBx3w6DI35yN4RrI3hrLfk57PPljntaGtw,616
|
|
14
|
+
noweda/plugins/missing.py,sha256=gcPgk5TSkpRi_0xABi8BQsAxHevUdPUI91HxbFRg0L0,219
|
|
15
|
+
noweda/plugins/outliers.py,sha256=DRmqFyimIBMHLbH_aXKH3nv3Xp18gHd9GFDd6vA9oRQ,447
|
|
16
|
+
noweda/plugins/pii.py,sha256=w3UYxeM7nnjEVJ4yHxCkaDl8ubodh62wTckz05mGlMw,505
|
|
17
|
+
noweda/plugins/schema.py,sha256=GwKNCgFEijZKbC6FuPuzTIV9Z5FQec6PUQ6SuRwPQ0A,2277
|
|
18
|
+
noweda/plugins/stats.py,sha256=8vRWxQvf5gxvFVdq_laplt06uDVZO36Qy2VTk-VLrpo,1361
|
|
19
|
+
noweda/report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
noweda/report/html.py,sha256=9dkQjDcgnJz2MaXw7DiQRLuBR2GU_pwHtJ1WYntIQvY,10192
|
|
21
|
+
noweda/scoring/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
noweda/scoring/scorer.py,sha256=gG5ttXEsroYvEAsmqP-MbuAV-JLfb3dqXs3sDkmfvD0,3103
|
|
23
|
+
noweda-0.1.0.dist-info/licenses/LICENSE,sha256=7zCIaXWjgEjqbSjHZfGkaVjwpZzrnC8PExoh-HArwog,1068
|
|
24
|
+
noweda-0.1.0.dist-info/METADATA,sha256=UlIcU3LVuLuFhzlXAt9lMWdnz1N71nLKoblXv-JsXrA,7024
|
|
25
|
+
noweda-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
26
|
+
noweda-0.1.0.dist-info/entry_points.txt,sha256=bPHLmGelOac4dnTCaMjzLQqdn-N3ZDXzA-3iceAXn-Q,43
|
|
27
|
+
noweda-0.1.0.dist-info/top_level.txt,sha256=z79WDaLX_ynq-UdeEYwwSgHLArVUj--W5hueoZF8pig,7
|
|
28
|
+
noweda-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Peng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
noweda
|