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.
@@ -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
+ ]