murthylytics 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.
- murthylytics/__init__.py +274 -0
- murthylytics/_version.py +3 -0
- murthylytics/cleaning/__init__.py +1 -0
- murthylytics/core/__init__.py +48 -0
- murthylytics/core/config.py +88 -0
- murthylytics/core/context.py +125 -0
- murthylytics/core/exceptions.py +54 -0
- murthylytics/core/logging.py +75 -0
- murthylytics/core/optional.py +32 -0
- murthylytics/core/types.py +50 -0
- murthylytics/eda/__init__.py +1 -0
- murthylytics/exploration/__init__.py +36 -0
- murthylytics/exploration/display.py +99 -0
- murthylytics/exploration/inspect.py +337 -0
- murthylytics/features/__init__.py +1 -0
- murthylytics/io/__init__.py +28 -0
- murthylytics/io/detect.py +125 -0
- murthylytics/io/optimize.py +84 -0
- murthylytics/io/readers.py +160 -0
- murthylytics/modeling/__init__.py +1 -0
- murthylytics/pipeline/__init__.py +1 -0
- murthylytics/plugins/__init__.py +1 -0
- murthylytics/preprocess/__init__.py +1 -0
- murthylytics/py.typed +0 -0
- murthylytics/viz/__init__.py +1 -0
- murthylytics-0.1.0.dist-info/METADATA +168 -0
- murthylytics-0.1.0.dist-info/RECORD +29 -0
- murthylytics-0.1.0.dist-info/WHEEL +4 -0
- murthylytics-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Helper for importing optional dependencies with a friendly error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .exceptions import MissingDependencyError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def optional_import(module: str, *, extra: Optional[str] = None) -> ModuleType:
|
|
13
|
+
"""Import ``module`` or raise a helpful :class:`MissingDependencyError`.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
module:
|
|
18
|
+
Importable module name, e.g. ``"matplotlib.pyplot"``.
|
|
19
|
+
extra:
|
|
20
|
+
The Murthylytics extra that installs it, e.g. ``"viz"``. Used to build
|
|
21
|
+
the ``pip install 'murthylytics[viz]'`` hint.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
return importlib.import_module(module)
|
|
25
|
+
except ImportError as exc: # pragma: no cover - exercised via feature tests
|
|
26
|
+
top = module.split(".")[0]
|
|
27
|
+
raise MissingDependencyError(top, extra) from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_module(module: str) -> bool:
|
|
31
|
+
"""Return True if ``module`` can be imported, without importing it fully."""
|
|
32
|
+
return importlib.util.find_spec(module) is not None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared type definitions and enumerations used across Murthylytics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING, Union
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
12
|
+
else:
|
|
13
|
+
PathLike = Union[str, "object"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ColumnRole(str, Enum):
|
|
17
|
+
"""Semantic role inferred for a dataframe column."""
|
|
18
|
+
|
|
19
|
+
NUMERIC = "numeric"
|
|
20
|
+
CATEGORICAL = "categorical"
|
|
21
|
+
BOOLEAN = "boolean"
|
|
22
|
+
DATETIME = "datetime"
|
|
23
|
+
TEXT = "text"
|
|
24
|
+
CONSTANT = "constant"
|
|
25
|
+
HIGH_CARDINALITY = "high_cardinality"
|
|
26
|
+
ID_LIKE = "id_like"
|
|
27
|
+
UNKNOWN = "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskType(str, Enum):
|
|
31
|
+
"""Machine-learning task inferred from the dataset and target."""
|
|
32
|
+
|
|
33
|
+
REGRESSION = "regression"
|
|
34
|
+
BINARY_CLASSIFICATION = "binary_classification"
|
|
35
|
+
MULTICLASS_CLASSIFICATION = "multiclass_classification"
|
|
36
|
+
CLUSTERING = "clustering"
|
|
37
|
+
TIME_SERIES = "time_series"
|
|
38
|
+
ANOMALY_DETECTION = "anomaly_detection"
|
|
39
|
+
NLP = "nlp"
|
|
40
|
+
RECOMMENDATION = "recommendation"
|
|
41
|
+
TABULAR = "tabular"
|
|
42
|
+
UNKNOWN = "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Severity(str, Enum):
|
|
46
|
+
"""Severity level for a reported data-quality issue."""
|
|
47
|
+
|
|
48
|
+
INFO = "info"
|
|
49
|
+
WARNING = "warning"
|
|
50
|
+
CRITICAL = "critical"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Murthylytics eda module (roadmap — see project README for status)."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Data exploration: display helpers and intelligent inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .display import (
|
|
6
|
+
bottom,
|
|
7
|
+
columns,
|
|
8
|
+
describe,
|
|
9
|
+
duplicates,
|
|
10
|
+
info,
|
|
11
|
+
memory,
|
|
12
|
+
missing,
|
|
13
|
+
shape,
|
|
14
|
+
summary,
|
|
15
|
+
top,
|
|
16
|
+
types,
|
|
17
|
+
)
|
|
18
|
+
from .inspect import ColumnProfile, InspectionReport, Issue, inspect
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"top",
|
|
22
|
+
"bottom",
|
|
23
|
+
"shape",
|
|
24
|
+
"columns",
|
|
25
|
+
"types",
|
|
26
|
+
"missing",
|
|
27
|
+
"duplicates",
|
|
28
|
+
"memory",
|
|
29
|
+
"summary",
|
|
30
|
+
"describe",
|
|
31
|
+
"info",
|
|
32
|
+
"inspect",
|
|
33
|
+
"InspectionReport",
|
|
34
|
+
"ColumnProfile",
|
|
35
|
+
"Issue",
|
|
36
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Lightweight dataframe exploration helpers.
|
|
2
|
+
|
|
3
|
+
These are pure functions over a :class:`pandas.DataFrame`; the facade binds them
|
|
4
|
+
to the active session so users can call ``mlt.summary()`` with no arguments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from ..io.optimize import memory_report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def top(df: pd.DataFrame, n: int = 5) -> pd.DataFrame:
|
|
17
|
+
"""Return the first ``n`` rows."""
|
|
18
|
+
return df.head(n)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bottom(df: pd.DataFrame, n: int = 5) -> pd.DataFrame:
|
|
22
|
+
"""Return the last ``n`` rows."""
|
|
23
|
+
return df.tail(n)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def shape(df: pd.DataFrame) -> Dict[str, int]:
|
|
27
|
+
"""Return row/column counts as a dict."""
|
|
28
|
+
rows, cols = df.shape
|
|
29
|
+
return {"rows": int(rows), "columns": int(cols)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def columns(df: pd.DataFrame) -> list:
|
|
33
|
+
"""Return the list of column names."""
|
|
34
|
+
return list(df.columns)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def types(df: pd.DataFrame) -> Dict[str, str]:
|
|
38
|
+
"""Return a mapping of column name to dtype string."""
|
|
39
|
+
return {c: str(t) for c, t in df.dtypes.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def missing(df: pd.DataFrame) -> pd.DataFrame:
|
|
43
|
+
"""Return per-column missing counts and percentages, sorted worst-first."""
|
|
44
|
+
count = df.isna().sum()
|
|
45
|
+
pct = (count / len(df) * 100).round(2) if len(df) else count.astype(float)
|
|
46
|
+
out = pd.DataFrame({"missing_count": count, "missing_pct": pct})
|
|
47
|
+
return out[out["missing_count"] > 0].sort_values("missing_count", ascending=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def duplicates(df: pd.DataFrame) -> Dict[str, Any]:
|
|
51
|
+
"""Return duplicate-row statistics."""
|
|
52
|
+
dup_mask = df.duplicated()
|
|
53
|
+
total = int(dup_mask.sum())
|
|
54
|
+
return {
|
|
55
|
+
"duplicate_rows": total,
|
|
56
|
+
"duplicate_pct": round(total / len(df) * 100, 2) if len(df) else 0.0,
|
|
57
|
+
"unique_rows": int(len(df) - total),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def memory(df: pd.DataFrame) -> Dict[str, float]:
|
|
62
|
+
"""Return a per-dtype memory footprint report in MB."""
|
|
63
|
+
return memory_report(df)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def summary(df: pd.DataFrame) -> Dict[str, Any]:
|
|
67
|
+
"""Return a compact, JSON-serialisable overview of the dataframe."""
|
|
68
|
+
miss = int(df.isna().sum().sum())
|
|
69
|
+
cells = int(df.size) or 1
|
|
70
|
+
return {
|
|
71
|
+
"shape": shape(df),
|
|
72
|
+
"columns": len(df.columns),
|
|
73
|
+
"memory_mb": round(memory_report(df)["total"], 3),
|
|
74
|
+
"missing_cells": miss,
|
|
75
|
+
"missing_pct": round(miss / cells * 100, 2),
|
|
76
|
+
"duplicate_rows": duplicates(df)["duplicate_rows"],
|
|
77
|
+
"numeric_columns": len(df.select_dtypes(include="number").columns),
|
|
78
|
+
"categorical_columns": len(df.select_dtypes(include=["object", "category"]).columns),
|
|
79
|
+
"datetime_columns": len(df.select_dtypes(include="datetime").columns),
|
|
80
|
+
"boolean_columns": len(df.select_dtypes(include="bool").columns),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def describe(df: pd.DataFrame, include: str = "all") -> pd.DataFrame:
|
|
85
|
+
"""Descriptive statistics wrapper around :meth:`pandas.DataFrame.describe`."""
|
|
86
|
+
try:
|
|
87
|
+
return df.describe(include=include)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return df.describe()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def info(df: pd.DataFrame) -> Dict[str, Any]:
|
|
93
|
+
"""Structured equivalent of :meth:`pandas.DataFrame.info` as a dict."""
|
|
94
|
+
return {
|
|
95
|
+
"shape": shape(df),
|
|
96
|
+
"dtypes": types(df),
|
|
97
|
+
"non_null_counts": {c: int(df[c].notna().sum()) for c in df.columns},
|
|
98
|
+
"memory_mb": round(memory_report(df)["total"], 3),
|
|
99
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Intelligent dataset inspection.
|
|
2
|
+
|
|
3
|
+
:func:`inspect` walks a dataframe once and produces an :class:`InspectionReport`
|
|
4
|
+
describing dimensions, column roles, missingness, duplicates, likely target
|
|
5
|
+
column(s), class imbalance, strong correlations, data-quality issues, potential
|
|
6
|
+
data-leakage risks and concrete recommendations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ..core.config import DEFAULT_CONFIG, Config
|
|
18
|
+
from ..core.types import ColumnRole, Severity
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ColumnProfile:
|
|
23
|
+
"""Per-column facts inferred during inspection."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
dtype: str
|
|
27
|
+
role: ColumnRole
|
|
28
|
+
missing_count: int
|
|
29
|
+
missing_pct: float
|
|
30
|
+
unique_count: int
|
|
31
|
+
unique_ratio: float
|
|
32
|
+
sample_values: List[Any] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Issue:
|
|
37
|
+
"""A single data-quality finding."""
|
|
38
|
+
|
|
39
|
+
severity: Severity
|
|
40
|
+
column: Optional[str]
|
|
41
|
+
message: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class InspectionReport:
|
|
46
|
+
"""Structured result of :func:`inspect`."""
|
|
47
|
+
|
|
48
|
+
n_rows: int
|
|
49
|
+
n_columns: int
|
|
50
|
+
memory_mb: float
|
|
51
|
+
duplicate_rows: int
|
|
52
|
+
total_missing_cells: int
|
|
53
|
+
missing_pct: float
|
|
54
|
+
columns: List[ColumnProfile] = field(default_factory=list)
|
|
55
|
+
roles: Dict[str, List[str]] = field(default_factory=dict)
|
|
56
|
+
potential_targets: List[str] = field(default_factory=list)
|
|
57
|
+
class_imbalance: Dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
strong_correlations: List[Tuple[str, str, float]] = field(default_factory=list)
|
|
59
|
+
leakage_risks: List[str] = field(default_factory=list)
|
|
60
|
+
issues: List[Issue] = field(default_factory=list)
|
|
61
|
+
recommendations: List[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
64
|
+
"""Return a fully JSON-serialisable representation."""
|
|
65
|
+
d = asdict(self)
|
|
66
|
+
# Enums -> their string values.
|
|
67
|
+
for col in d["columns"]:
|
|
68
|
+
col["role"] = col["role"].value if hasattr(col["role"], "value") else str(col["role"])
|
|
69
|
+
for iss in d["issues"]:
|
|
70
|
+
iss["severity"] = (
|
|
71
|
+
iss["severity"].value if hasattr(iss["severity"], "value") else str(iss["severity"])
|
|
72
|
+
)
|
|
73
|
+
return d
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
76
|
+
lines = [
|
|
77
|
+
"InspectionReport",
|
|
78
|
+
f" shape : {self.n_rows} rows x {self.n_columns} cols",
|
|
79
|
+
f" memory : {self.memory_mb:.3f} MB",
|
|
80
|
+
f" missing cells : {self.total_missing_cells} ({self.missing_pct:.2f}%)",
|
|
81
|
+
f" duplicate rows : {self.duplicate_rows}",
|
|
82
|
+
f" potential target: {', '.join(self.potential_targets) or 'none detected'}",
|
|
83
|
+
f" issues : {len(self.issues)}",
|
|
84
|
+
f" recommendations : {len(self.recommendations)}",
|
|
85
|
+
]
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _classify_column(s: pd.Series, n_rows: int, config: Config) -> Tuple[ColumnRole, int, float]:
|
|
90
|
+
"""Infer a semantic :class:`ColumnRole` for a single series."""
|
|
91
|
+
nunique = int(s.nunique(dropna=True))
|
|
92
|
+
unique_ratio = nunique / n_rows if n_rows else 0.0
|
|
93
|
+
|
|
94
|
+
if nunique <= 1:
|
|
95
|
+
return ColumnRole.CONSTANT, nunique, unique_ratio
|
|
96
|
+
|
|
97
|
+
if pd.api.types.is_bool_dtype(s):
|
|
98
|
+
return ColumnRole.BOOLEAN, nunique, unique_ratio
|
|
99
|
+
if pd.api.types.is_datetime64_any_dtype(s):
|
|
100
|
+
return ColumnRole.DATETIME, nunique, unique_ratio
|
|
101
|
+
|
|
102
|
+
if pd.api.types.is_numeric_dtype(s):
|
|
103
|
+
# A near-unique integer column is more likely an identifier than a feature.
|
|
104
|
+
if pd.api.types.is_integer_dtype(s) and unique_ratio >= config.id_unique_ratio:
|
|
105
|
+
return ColumnRole.ID_LIKE, nunique, unique_ratio
|
|
106
|
+
return ColumnRole.NUMERIC, nunique, unique_ratio
|
|
107
|
+
|
|
108
|
+
# Object / string / category.
|
|
109
|
+
if unique_ratio >= config.id_unique_ratio:
|
|
110
|
+
# Distinguish free-text from identifiers by average token length.
|
|
111
|
+
avg_len = s.dropna().astype(str).str.len().mean() if nunique else 0
|
|
112
|
+
if avg_len and avg_len > 40:
|
|
113
|
+
return ColumnRole.TEXT, nunique, unique_ratio
|
|
114
|
+
return ColumnRole.ID_LIKE, nunique, unique_ratio
|
|
115
|
+
if unique_ratio >= config.high_cardinality_ratio:
|
|
116
|
+
return ColumnRole.HIGH_CARDINALITY, nunique, unique_ratio
|
|
117
|
+
# Long strings with moderate cardinality read as text (reviews, descriptions).
|
|
118
|
+
avg_len = s.dropna().astype(str).str.len().mean() if nunique else 0
|
|
119
|
+
if avg_len and avg_len > 60:
|
|
120
|
+
return ColumnRole.TEXT, nunique, unique_ratio
|
|
121
|
+
return ColumnRole.CATEGORICAL, nunique, unique_ratio
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _detect_targets(profiles: List[ColumnProfile], df: pd.DataFrame) -> List[str]:
|
|
125
|
+
"""Heuristically rank columns by their likelihood of being the target.
|
|
126
|
+
|
|
127
|
+
Signals: conventional names, being the last column, low-cardinality
|
|
128
|
+
categoricals (classification) and complete numeric columns (regression).
|
|
129
|
+
"""
|
|
130
|
+
name_hints = {"target", "label", "class", "y", "outcome", "output", "result", "churn"}
|
|
131
|
+
scored: List[Tuple[float, str]] = []
|
|
132
|
+
for i, p in enumerate(profiles):
|
|
133
|
+
if p.role in (ColumnRole.ID_LIKE, ColumnRole.CONSTANT, ColumnRole.TEXT):
|
|
134
|
+
continue
|
|
135
|
+
score = 0.0
|
|
136
|
+
lname = p.name.lower()
|
|
137
|
+
if lname in name_hints:
|
|
138
|
+
score += 5.0
|
|
139
|
+
elif any(h in lname for h in name_hints if len(h) >= 4):
|
|
140
|
+
# Substring matching only for longer hints, so "y" doesn't match "city".
|
|
141
|
+
score += 3.0
|
|
142
|
+
if i == len(profiles) - 1: # last column convention
|
|
143
|
+
score += 1.5
|
|
144
|
+
if p.role in (ColumnRole.CATEGORICAL, ColumnRole.BOOLEAN) and 2 <= p.unique_count <= 20:
|
|
145
|
+
score += 2.0
|
|
146
|
+
if p.missing_count == 0:
|
|
147
|
+
score += 0.5
|
|
148
|
+
if score > 0:
|
|
149
|
+
scored.append((score, p.name))
|
|
150
|
+
scored.sort(reverse=True)
|
|
151
|
+
return [name for _, name in scored[:3]]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _class_imbalance(df: pd.DataFrame, target: str, config: Config) -> Dict[str, Any]:
|
|
155
|
+
"""Compute class-frequency imbalance for a candidate target column."""
|
|
156
|
+
counts = df[target].value_counts(dropna=True)
|
|
157
|
+
if counts.empty:
|
|
158
|
+
return {}
|
|
159
|
+
minority, majority = int(counts.min()), int(counts.max())
|
|
160
|
+
ratio = minority / majority if majority else 0.0
|
|
161
|
+
return {
|
|
162
|
+
"target": target,
|
|
163
|
+
"n_classes": int(counts.size),
|
|
164
|
+
"class_counts": {str(k): int(v) for k, v in counts.head(20).items()},
|
|
165
|
+
"imbalance_ratio": round(ratio, 4),
|
|
166
|
+
"is_imbalanced": bool(ratio < config.class_imbalance_ratio and counts.size > 1),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _strong_correlations(
|
|
171
|
+
df: pd.DataFrame, config: Config
|
|
172
|
+
) -> List[Tuple[str, str, float]]:
|
|
173
|
+
"""Return numeric column pairs with |correlation| above the threshold."""
|
|
174
|
+
num = df.select_dtypes(include="number")
|
|
175
|
+
if num.shape[1] < 2:
|
|
176
|
+
return []
|
|
177
|
+
corr = num.corr(numeric_only=True).abs()
|
|
178
|
+
pairs: List[Tuple[str, str, float]] = []
|
|
179
|
+
cols = corr.columns
|
|
180
|
+
for i in range(len(cols)):
|
|
181
|
+
for j in range(i + 1, len(cols)):
|
|
182
|
+
val = corr.iloc[i, j]
|
|
183
|
+
if pd.notna(val) and val >= config.correlation_threshold:
|
|
184
|
+
pairs.append((cols[i], cols[j], round(float(val), 4)))
|
|
185
|
+
pairs.sort(key=lambda t: t[2], reverse=True)
|
|
186
|
+
return pairs
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def inspect(df: pd.DataFrame, *, config: Config = DEFAULT_CONFIG) -> InspectionReport:
|
|
190
|
+
"""Run a full intelligent inspection of ``df`` and return a report."""
|
|
191
|
+
n_rows, n_cols = df.shape
|
|
192
|
+
total_missing = int(df.isna().sum().sum())
|
|
193
|
+
cells = df.size or 1
|
|
194
|
+
|
|
195
|
+
profiles: List[ColumnProfile] = []
|
|
196
|
+
roles: Dict[str, List[str]] = {r.value: [] for r in ColumnRole}
|
|
197
|
+
issues: List[Issue] = []
|
|
198
|
+
|
|
199
|
+
for col in df.columns:
|
|
200
|
+
s = df[col]
|
|
201
|
+
role, nunique, uratio = _classify_column(s, n_rows, config)
|
|
202
|
+
miss = int(s.isna().sum())
|
|
203
|
+
profiles.append(
|
|
204
|
+
ColumnProfile(
|
|
205
|
+
name=str(col),
|
|
206
|
+
dtype=str(s.dtype),
|
|
207
|
+
role=role,
|
|
208
|
+
missing_count=miss,
|
|
209
|
+
missing_pct=round(miss / n_rows * 100, 2) if n_rows else 0.0,
|
|
210
|
+
unique_count=nunique,
|
|
211
|
+
unique_ratio=round(uratio, 4),
|
|
212
|
+
sample_values=[_safe(v) for v in s.dropna().unique()[:5]],
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
roles[role.value].append(str(col))
|
|
216
|
+
|
|
217
|
+
# Per-column data-quality issues.
|
|
218
|
+
if role == ColumnRole.CONSTANT:
|
|
219
|
+
issues.append(Issue(Severity.WARNING, str(col), "Column is constant (no variance)."))
|
|
220
|
+
if n_rows and miss / n_rows >= config.missing_drop_column_ratio:
|
|
221
|
+
issues.append(
|
|
222
|
+
Issue(
|
|
223
|
+
Severity.CRITICAL,
|
|
224
|
+
str(col),
|
|
225
|
+
f"{miss / n_rows * 100:.1f}% missing — consider dropping.",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
if role == ColumnRole.ID_LIKE:
|
|
229
|
+
issues.append(
|
|
230
|
+
Issue(Severity.INFO, str(col), "Looks like an identifier — likely not predictive.")
|
|
231
|
+
)
|
|
232
|
+
if _has_infinite(s):
|
|
233
|
+
issues.append(Issue(Severity.WARNING, str(col), "Contains infinite values."))
|
|
234
|
+
|
|
235
|
+
# Dataset-level findings.
|
|
236
|
+
duplicate_rows = int(df.duplicated().sum())
|
|
237
|
+
if duplicate_rows:
|
|
238
|
+
issues.append(
|
|
239
|
+
Issue(Severity.WARNING, None, f"{duplicate_rows} duplicate rows detected.")
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
potential_targets = _detect_targets(profiles, df)
|
|
243
|
+
class_imbalance: Dict[str, Any] = {}
|
|
244
|
+
if potential_targets:
|
|
245
|
+
ci = _class_imbalance(df, potential_targets[0], config)
|
|
246
|
+
if ci.get("is_imbalanced"):
|
|
247
|
+
issues.append(
|
|
248
|
+
Issue(
|
|
249
|
+
Severity.WARNING,
|
|
250
|
+
potential_targets[0],
|
|
251
|
+
f"Class imbalance (ratio={ci['imbalance_ratio']}). Consider resampling.",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
class_imbalance = ci
|
|
255
|
+
|
|
256
|
+
strong = _strong_correlations(df, config)
|
|
257
|
+
leakage_risks: List[str] = []
|
|
258
|
+
if potential_targets:
|
|
259
|
+
tgt = potential_targets[0]
|
|
260
|
+
for a, b, v in strong:
|
|
261
|
+
if tgt in (a, b) and v >= 0.98:
|
|
262
|
+
other = b if a == tgt else a
|
|
263
|
+
leakage_risks.append(other)
|
|
264
|
+
issues.append(
|
|
265
|
+
Issue(
|
|
266
|
+
Severity.CRITICAL,
|
|
267
|
+
other,
|
|
268
|
+
f"Almost perfectly correlated with target '{tgt}' (r={v}); "
|
|
269
|
+
"possible data leakage.",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
report = InspectionReport(
|
|
274
|
+
n_rows=int(n_rows),
|
|
275
|
+
n_columns=int(n_cols),
|
|
276
|
+
memory_mb=round(float(df.memory_usage(deep=True).sum()) / 1e6, 3),
|
|
277
|
+
duplicate_rows=duplicate_rows,
|
|
278
|
+
total_missing_cells=total_missing,
|
|
279
|
+
missing_pct=round(total_missing / cells * 100, 2),
|
|
280
|
+
columns=profiles,
|
|
281
|
+
roles={k: v for k, v in roles.items() if v},
|
|
282
|
+
potential_targets=potential_targets,
|
|
283
|
+
class_imbalance=class_imbalance,
|
|
284
|
+
strong_correlations=strong,
|
|
285
|
+
leakage_risks=leakage_risks,
|
|
286
|
+
issues=issues,
|
|
287
|
+
)
|
|
288
|
+
report.recommendations = _recommendations(report)
|
|
289
|
+
return report
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _recommendations(r: InspectionReport) -> List[str]:
|
|
293
|
+
"""Derive actionable recommendations from the assembled report."""
|
|
294
|
+
recs: List[str] = []
|
|
295
|
+
if r.duplicate_rows:
|
|
296
|
+
recs.append(f"Remove {r.duplicate_rows} duplicate rows with `mlt.clean_data()`.")
|
|
297
|
+
if r.total_missing_cells:
|
|
298
|
+
recs.append("Impute or drop missing values before modelling.")
|
|
299
|
+
if r.roles.get(ColumnRole.ID_LIKE.value):
|
|
300
|
+
recs.append(
|
|
301
|
+
"Drop identifier columns "
|
|
302
|
+
f"({', '.join(r.roles[ColumnRole.ID_LIKE.value])}) — they add no signal."
|
|
303
|
+
)
|
|
304
|
+
if r.roles.get(ColumnRole.CONSTANT.value):
|
|
305
|
+
recs.append("Drop constant columns — they carry no information.")
|
|
306
|
+
if r.roles.get(ColumnRole.HIGH_CARDINALITY.value):
|
|
307
|
+
recs.append("Use target/frequency encoding for high-cardinality categoricals.")
|
|
308
|
+
if r.leakage_risks:
|
|
309
|
+
recs.append(
|
|
310
|
+
f"Investigate potential leakage from {', '.join(r.leakage_risks)} before training."
|
|
311
|
+
)
|
|
312
|
+
if r.class_imbalance.get("is_imbalanced"):
|
|
313
|
+
recs.append("Address class imbalance (e.g. SMOTE, class weights) during preprocessing.")
|
|
314
|
+
if r.strong_correlations:
|
|
315
|
+
recs.append("Consider dropping one of each strongly correlated numeric pair.")
|
|
316
|
+
if not recs:
|
|
317
|
+
recs.append("No major issues detected — data looks clean.")
|
|
318
|
+
return recs
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _safe(value: Any) -> Any:
|
|
322
|
+
"""Coerce numpy/pandas scalars to plain Python for JSON-friendliness."""
|
|
323
|
+
if isinstance(value, (np.integer,)):
|
|
324
|
+
return int(value)
|
|
325
|
+
if isinstance(value, (np.floating,)):
|
|
326
|
+
return float(value)
|
|
327
|
+
if isinstance(value, (np.bool_,)):
|
|
328
|
+
return bool(value)
|
|
329
|
+
if isinstance(value, pd.Timestamp):
|
|
330
|
+
return value.isoformat()
|
|
331
|
+
return value
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _has_infinite(s: pd.Series) -> bool:
|
|
335
|
+
if not pd.api.types.is_float_dtype(s):
|
|
336
|
+
return False
|
|
337
|
+
return bool(np.isinf(s.to_numpy(dtype="float64", na_value=np.nan)).any())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Murthylytics features module (roadmap — see project README for status)."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Data input/output: smart readers, detection and memory optimisation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .detect import (
|
|
6
|
+
coerce_datetimes,
|
|
7
|
+
detect_datetime_columns,
|
|
8
|
+
detect_delimiter,
|
|
9
|
+
detect_encoding,
|
|
10
|
+
)
|
|
11
|
+
from .optimize import estimate_savings, memory_report, memory_usage_bytes, optimize_memory
|
|
12
|
+
from .readers import read_csv, read_excel, read_json, read_parquet, read_sql
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"read_csv",
|
|
16
|
+
"read_excel",
|
|
17
|
+
"read_json",
|
|
18
|
+
"read_parquet",
|
|
19
|
+
"read_sql",
|
|
20
|
+
"detect_encoding",
|
|
21
|
+
"detect_delimiter",
|
|
22
|
+
"detect_datetime_columns",
|
|
23
|
+
"coerce_datetimes",
|
|
24
|
+
"optimize_memory",
|
|
25
|
+
"memory_report",
|
|
26
|
+
"memory_usage_bytes",
|
|
27
|
+
"estimate_savings",
|
|
28
|
+
]
|