elessar 1.0.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.
elessar/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """elessar: automated exploratory data analysis and data-quality profiling for pandas DataFrames.
2
+
3
+ >>> import elessar
4
+ >>> report = elessar.see(df, target="price") # prints the report, writes plots + report.html
5
+ >>> report = elessar.see("sales.parquet", target="ask") # any file; pick the target in a window
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+
10
+ from .config import StoneConfig # noqa: E402 - __version__ first: submodules read it
11
+ from .core import see, see_column # noqa: E402
12
+ from .dates import parse_dates # noqa: E402
13
+ from .correlations import see_corr # noqa: E402
14
+ from .distributions import see_dist # noqa: E402
15
+ from .picker import pick_target, suggest_targets # noqa: E402
16
+ from .plots import see_plot # noqa: E402
17
+ from .quality import see_quality # noqa: E402
18
+ from .report import Vision # noqa: E402
19
+ from .sources import LoadError, Source, load # noqa: E402
20
+ from .stats import see_stats # noqa: E402
21
+ from .types import see_types # noqa: E402
22
+
23
+ __all__ = [
24
+ "LoadError",
25
+ "Source",
26
+ "StoneConfig",
27
+ "Vision",
28
+ "load",
29
+ "parse_dates",
30
+ "pick_target",
31
+ "see",
32
+ "see_column",
33
+ "see_corr",
34
+ "see_dist",
35
+ "see_plot",
36
+ "see_quality",
37
+ "see_stats",
38
+ "see_types",
39
+ "suggest_targets",
40
+ ]
elessar/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
elessar/_utils.py ADDED
@@ -0,0 +1,216 @@
1
+ """Small shared helpers (private)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import warnings
8
+ from contextlib import contextmanager
9
+ from typing import Any, Iterator
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ UNHASHABLE = (list, dict, set, np.ndarray)
15
+ REPR_TYPES = (*UNHASHABLE, bytes, bytearray)
16
+
17
+
18
+ @contextmanager
19
+ def quiet() -> Iterator[None]:
20
+ """Silence numpy/scipy/pandas warnings inside the block."""
21
+ with warnings.catch_warnings(), np.errstate(all="ignore"):
22
+ warnings.simplefilter("ignore")
23
+ yield
24
+
25
+
26
+ def is_text_dtype(s: pd.Series) -> bool:
27
+ """True for object columns and pandas string dtypes."""
28
+ return pd.api.types.is_object_dtype(s.dtype) or isinstance(s.dtype, pd.StringDtype)
29
+
30
+
31
+ def hashable(s: pd.Series) -> pd.Series:
32
+ """Return ``s`` with unhashable cells (lists, dicts, ...) and raw bytes replaced by their repr.
33
+
34
+ Bytes are included because pandas decodes them as UTF-8 when casting to str, which can fail.
35
+ """
36
+ if not pd.api.types.is_object_dtype(s.dtype):
37
+ return s
38
+ if not s.map(lambda v: isinstance(v, REPR_TYPES)).any():
39
+ return s
40
+ return s.map(lambda v: repr(v) if isinstance(v, REPR_TYPES) else v)
41
+
42
+
43
+ def sample(obj: pd.Series | pd.DataFrame, n: int, random_state: int) -> pd.Series | pd.DataFrame:
44
+ """Deterministic row sample that preserves the original row order."""
45
+ if len(obj) <= n:
46
+ return obj
47
+ rng = np.random.default_rng(random_state)
48
+ pos = np.sort(rng.choice(len(obj), size=n, replace=False))
49
+ return obj.iloc[pos]
50
+
51
+
52
+ def sample_array(x: np.ndarray, n: int, random_state: int) -> np.ndarray:
53
+ if len(x) <= n:
54
+ return x
55
+ rng = np.random.default_rng(random_state)
56
+ return x[np.sort(rng.choice(len(x), size=n, replace=False))]
57
+
58
+
59
+ def finite(x: Any) -> float | None:
60
+ """Convert to a plain float, mapping NaN/inf/errors to None."""
61
+ try:
62
+ f = float(x)
63
+ except (TypeError, ValueError):
64
+ return None
65
+ return f if math.isfinite(f) else None
66
+
67
+
68
+ def jsonable(obj: Any) -> Any:
69
+ """Recursively convert numpy / pandas objects to JSON-serialisable Python."""
70
+ if isinstance(obj, dict):
71
+ return {str(k): jsonable(v) for k, v in obj.items()}
72
+ if isinstance(obj, (list, tuple, set)):
73
+ return [jsonable(v) for v in obj]
74
+ if isinstance(obj, pd.DataFrame):
75
+ return [jsonable(r) for r in obj.reset_index().to_dict(orient="records")]
76
+ if isinstance(obj, pd.Series):
77
+ return {str(k): jsonable(v) for k, v in obj.items()}
78
+ if isinstance(obj, np.ndarray):
79
+ return [jsonable(v) for v in obj.tolist()]
80
+ if isinstance(obj, (bool, np.bool_)):
81
+ return bool(obj)
82
+ if isinstance(obj, (int, np.integer)):
83
+ return int(obj)
84
+ if isinstance(obj, (float, np.floating)):
85
+ return finite(obj)
86
+ if isinstance(obj, (pd.Timestamp, pd.Timedelta)):
87
+ return str(obj)
88
+ if obj is None or isinstance(obj, str):
89
+ return obj
90
+ try:
91
+ if pd.isna(obj):
92
+ return None
93
+ except (TypeError, ValueError):
94
+ pass
95
+ return str(obj)
96
+
97
+
98
+ def dedupe_names(names: list[Any]) -> list[str]:
99
+ """Stringify column names and make them unique (``a``, ``a.1``, ...)."""
100
+ out: list[str] = []
101
+ seen: dict[str, int] = {}
102
+ for name in names:
103
+ base = "|".join(map(str, name)) if isinstance(name, tuple) else str(name)
104
+ key = base
105
+ while key in seen:
106
+ seen[base] += 1
107
+ key = f"{base}.{seen[base]}"
108
+ seen.setdefault(key, 0)
109
+ out.append(key)
110
+ return out
111
+
112
+
113
+ def _as_frame(df: pd.DataFrame | pd.Series) -> pd.DataFrame:
114
+ if isinstance(df, pd.Series):
115
+ return df.to_frame(name="value" if df.name is None else df.name)
116
+ if not isinstance(df, pd.DataFrame):
117
+ raise TypeError(f"expected a pandas DataFrame or Series, got {type(df).__name__}")
118
+ return df
119
+
120
+
121
+ def prepare(df: pd.DataFrame | pd.Series) -> pd.DataFrame:
122
+ """Accept a Series or DataFrame; return a DataFrame safe to analyse.
123
+
124
+ Column names become unique strings (``a``, ``a.1``, tuples joined with ``|``), sparse columns
125
+ are densified, and a non-unique row index is replaced by row positions.
126
+ """
127
+ df = _as_frame(df)
128
+ names = dedupe_names(list(df.columns))
129
+ if names != list(df.columns):
130
+ df = df.set_axis(names, axis=1)
131
+ sparse = [c for c in df.columns if isinstance(df[c].dtype, pd.SparseDtype)]
132
+ if sparse:
133
+ df = df.assign(**{c: df[c].sparse.to_dense() for c in sparse})
134
+ if not df.index.is_unique:
135
+ df = df.reset_index(drop=True)
136
+ return df
137
+
138
+
139
+ def _same_label(a: Any, b: Any) -> bool:
140
+ if a is b:
141
+ return True
142
+ try:
143
+ if bool(a == b):
144
+ return True
145
+ except (TypeError, ValueError):
146
+ pass
147
+ try:
148
+ return bool(pd.isna(a) and pd.isna(b))
149
+ except (TypeError, ValueError):
150
+ return False
151
+
152
+
153
+ def resolve_target(df: pd.DataFrame | pd.Series, target: Any) -> str | None:
154
+ """Map a target given as an original column label (any hashable) to its name after :func:`prepare`."""
155
+ if target is None:
156
+ return None
157
+ cols = list(_as_frame(df).columns)
158
+ names = dedupe_names(cols)
159
+ for label, name in zip(cols, names):
160
+ if _same_label(label, target):
161
+ return name
162
+ if str(target) in names:
163
+ return str(target)
164
+ raise KeyError(f"target column {target!r} not found")
165
+
166
+
167
+ def fmt_num(x: Any, digits: int = 4) -> str:
168
+ """Compact human formatting for numbers (thousands separators, sig. digits)."""
169
+ if x is None:
170
+ return "-"
171
+ if isinstance(x, (bool, np.bool_)):
172
+ return str(bool(x))
173
+ if isinstance(x, (int, np.integer)):
174
+ return f"{int(x):,}"
175
+ f = finite(x)
176
+ if f is None:
177
+ return str(x)
178
+ if f == 0:
179
+ return "0"
180
+ if abs(f) >= 1e15 or abs(f) < 1e-3:
181
+ return f"{f:.{digits - 1}e}"
182
+ if f.is_integer() and abs(f) < 1e15:
183
+ return f"{int(f):,}"
184
+ return f"{f:,.{digits}g}" if abs(f) < 1000 else f"{f:,.1f}"
185
+
186
+
187
+ def fmt_p(p: Any) -> str:
188
+ """Compact p-value: 0.032, 4e-07, < 1e-300 (exact zero after underflow), n/a."""
189
+ f = finite(p)
190
+ if f is None:
191
+ return "n/a"
192
+ if f == 0:
193
+ return "< 1e-300"
194
+ return f"{f:.2g}" if f >= 1e-3 else f"{f:.0e}"
195
+
196
+
197
+ def fmt_bytes(n: float | None) -> str:
198
+ if n is None:
199
+ return "-"
200
+ for unit in ("B", "KB", "MB", "GB"):
201
+ if abs(n) < 1024:
202
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
203
+ n /= 1024
204
+ return f"{n:.1f} TB"
205
+
206
+
207
+ def safe_name(name: Any) -> str:
208
+ """Filesystem-safe version of a column name (used in plot file names)."""
209
+ return re.sub(r"[^\w.-]+", "_", str(name)).strip("_")[:40] or "col"
210
+
211
+
212
+ def truncate(text: Any, width: int) -> str:
213
+ s = str(text).replace("\n", " ")
214
+ if width <= 1:
215
+ return s[:width]
216
+ return s if len(s) <= width else s[: width - 1] + "…"
elessar/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ """Command line: ``elessar data.csv --target y`` (also ``python -m elessar``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__, recap
10
+ from .config import StoneConfig
11
+ from .core import see
12
+ from .picker import pick_target
13
+ from .sources import LoadError, load
14
+ from .types import FORCEABLE_TYPES
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(
19
+ prog="elessar",
20
+ description="Automated exploratory data analysis and data-quality profiling: semantic types, statistics, "
21
+ "distributions, associations with significance, target analysis, data quality, charts and an HTML report.",
22
+ )
23
+ p.add_argument("source", help="file, URL, glob pattern ('logs/*.csv') or folder: CSV / TSV / TXT, JSON / JSONL, "
24
+ "Parquet, Feather, ORC, Excel / ODS, pickle, HDF5, SAS, Stata, SPSS, SQLite, XML "
25
+ "(compressed or not)")
26
+ p.add_argument("-V", "--version", action="version", version=f"elessar {__version__}")
27
+ target = p.add_mutually_exclusive_group()
28
+ target.add_argument("-t", "--target", help="target column to analyse features against ('ask' opens a picker)")
29
+ target.add_argument("--no-target", action="store_true", help="do not ask for a target")
30
+ p.add_argument("-o", "--out", help="output folder (default: elessar_report/<file>_<target-X or no-target>_<YYYY-MM-DD>/)")
31
+ p.add_argument("--type", action="append", default=[], metavar="COL=TYPE",
32
+ help="set a column's type by hand (repeatable), e.g. --type region=categorical --type cylinders="
33
+ "numeric_discrete")
34
+ p.add_argument("--no-plots", action="store_true", help="skip the charts (and the HTML recap)")
35
+ p.add_argument("--single-file", action="store_true", help="also write report_single.html with the charts inlined")
36
+ read = p.add_argument_group("reading")
37
+ read.add_argument("--sheet", help="Excel sheet name or number (default: the first sheet with data)")
38
+ read.add_argument("--table", help="SQLite table / HDF5 key (default: the biggest table)")
39
+ read.add_argument("--sep", help="CSV delimiter (default: sniffed)")
40
+ read.add_argument("--encoding", help="text encoding (default: sniffed)")
41
+ read.add_argument("--columns", help="comma-separated columns to read")
42
+ read.add_argument("--nrows", type=int, help="only read the first N rows")
43
+ read.add_argument("--max-rows", type=int, help="profile at most N rows; bigger tables are streamed and sampled, "
44
+ "with exact counts over every row (default 1000000)")
45
+ read.add_argument("--engine", choices=["auto", "dask", "pandas"], default="auto",
46
+ help="streaming engine for big files (auto: dask when installed)")
47
+ p.add_argument("-j", "--jobs", type=int, help="processes for the heavy steps (default: automatic; 1 = none)")
48
+ p.add_argument("--sample", type=int, help="max rows for the expensive tests (default 100000)")
49
+ p.add_argument("--alpha", type=float, help="significance level (default 0.05)")
50
+ p.add_argument("--width", type=int, help="report width in characters (default: terminal width)")
51
+ p.add_argument("--json", action="store_true", help="also write <out>/report.json")
52
+ p.add_argument("--markdown", action="store_true", help="also write <out>/report.md")
53
+ p.add_argument("--csv", action="store_true", help="also write <out>/summary.csv")
54
+ p.add_argument("-q", "--quiet", action="store_true", help="do not print the report")
55
+ p.add_argument("--open", choices=["auto", "html", "md", "none"], default="auto",
56
+ help="open the recap when done: auto = HTML recap when run from a terminal (default)")
57
+ return p
58
+
59
+
60
+ def _interactive() -> bool:
61
+ return bool(sys.stdin and sys.stdin.isatty() and sys.stdout.isatty())
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ args = build_parser().parse_args(argv)
66
+ cfg = StoneConfig()
67
+ for flag, attr in (("sample", "sample_size"), ("alpha", "alpha"), ("max_rows", "max_rows"), ("jobs", "n_jobs")):
68
+ if getattr(args, flag) is not None:
69
+ setattr(cfg, attr, getattr(args, flag))
70
+ cfg.single_file_report = args.single_file
71
+ for spec in args.type:
72
+ col, sep, stype = spec.partition("=")
73
+ if not sep or not col.strip() or not stype.strip():
74
+ print(f"elessar: --type expects COL=TYPE, got {spec!r}", file=sys.stderr)
75
+ return 2
76
+ if stype.strip() not in FORCEABLE_TYPES:
77
+ print(f"elessar: --type {spec!r}: choose one of {', '.join(FORCEABLE_TYPES)}", file=sys.stderr)
78
+ return 2
79
+ cfg.column_types[col.strip()] = stype.strip()
80
+ sheet = int(args.sheet) if args.sheet is not None and args.sheet.isdigit() else args.sheet
81
+ columns = [c.strip() for c in args.columns.split(",")] if args.columns else None
82
+ try:
83
+ src = load(args.source, cfg, sheet=sheet, table=args.table, sep=args.sep, encoding=args.encoding,
84
+ columns=columns, nrows=args.nrows, engine=args.engine)
85
+ except (LoadError, OSError, ValueError, ImportError) as exc:
86
+ print(f"elessar: {exc}", file=sys.stderr)
87
+ return 2
88
+ target = args.target
89
+ if target == "ask" or (target is None and not args.no_target and _interactive()):
90
+ try:
91
+ target = pick_target(src.df)
92
+ except RuntimeError as exc:
93
+ print(f"elessar: {exc}", file=sys.stderr)
94
+ return 2
95
+ if target is not None and str(target) not in map(str, src.df.columns):
96
+ print(f"elessar: target {target!r} not in columns: {', '.join(map(str, src.df.columns))}", file=sys.stderr)
97
+ return 2
98
+ open_report = {"auto": None, "html": "html", "md": "md", "none": False}[args.open]
99
+ report = see(src, target=target, config=cfg, plots=not args.no_plots, out_dir=args.out, verbose=False,
100
+ open_report=False)
101
+ if not args.quiet:
102
+ report.print(width=args.width)
103
+ sys.stdout.flush() # keep the report before the "wrote ..." messages when both streams share a file
104
+ if recap.maybe_open(report, open_report, sys.stderr) is None and report.recap:
105
+ print(f"\nReport: {report.recap['html'].resolve()}", file=sys.stderr)
106
+ out = report.out_dir or Path(args.out or ".")
107
+ if args.json or args.markdown or args.csv:
108
+ out.mkdir(parents=True, exist_ok=True)
109
+ written = [
110
+ export(out / name)
111
+ for flag, name, export in (
112
+ (args.json, "report.json", report.to_json),
113
+ (args.markdown, "report.md", report.to_markdown),
114
+ (args.csv, "summary.csv", report.to_csv),
115
+ )
116
+ if flag
117
+ ]
118
+ for path in written:
119
+ print(f"wrote {path}", file=sys.stderr)
120
+ return 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ raise SystemExit(main())
elessar/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Configuration: every threshold used by elessar lives here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ CONTINUOUS_CANDIDATES: tuple[str, ...] = (
8
+ "norm",
9
+ "lognorm",
10
+ "expon",
11
+ "gamma",
12
+ "weibull_min",
13
+ "beta",
14
+ "uniform",
15
+ "logistic",
16
+ "t",
17
+ "laplace",
18
+ "pareto",
19
+ "chi2",
20
+ "cauchy",
21
+ )
22
+ DISCRETE_CANDIDATES: tuple[str, ...] = ("poisson", "nbinom", "geom", "binom")
23
+
24
+ PLACEHOLDER_STRINGS: tuple[str, ...] = (
25
+ "", "na", "n/a", "n.a.", "nan", "null", "none", "nil", "-", "--", "?", ".",
26
+ "missing", "unknown", "undefined", "#n/a", "#null!", "not available",
27
+ "0000-00-00", "00/00/0000", "00-00-0000", "0000/00/00", "0000-00-00 00:00:00", # zero dates: no date
28
+ )
29
+ PLACEHOLDER_NUMBERS: tuple[float, ...] = (-999, -9999, -99999, 999, 9999, 99999, 999999)
30
+
31
+
32
+ @dataclass
33
+ class StoneConfig:
34
+ """Thresholds and knobs. Percentages are expressed in 0-100."""
35
+
36
+ # sampling
37
+ sample_size: int = 100_000
38
+ """Max rows used by expensive steps (correlations, Kendall, KDE, quality pairs)."""
39
+ fit_sample_size: int = 20_000
40
+ """Max values used for distribution fitting and normality tests."""
41
+ random_state: int = 42
42
+ max_rows: int = 1_000_000
43
+ """Larger tables are profiled on a uniform random sample of this many rows; row counts, missing
44
+ values, min/max/mean/std, distinct counts and duplicate rows stay exact over every row."""
45
+ probe_max_rows: int = 200_000
46
+ """Most rows progressive typing reads before the full pass (big tables only)."""
47
+ chunk_rows: int = 250_000
48
+ """Rows per chunk when a file is streamed (files too big to profile in one piece)."""
49
+ max_hashed_rows: int | None = None
50
+ """Duplicate rows are counted exactly up to this many rows (16 bytes of memory per row); None = as
51
+ many as a quarter of the available memory allows."""
52
+ memory_fraction: float = 0.5
53
+ """Share of the available RAM a table may take once loaded; bigger files are streamed."""
54
+
55
+ # parallelism
56
+ n_jobs: int | None = None
57
+ """Processes for the heavy steps: None = automatic (all cores for big tables), 1 = no
58
+ parallelism, -1 = all cores, k = at most k."""
59
+ parallel_min_cells: int = 20_000
60
+ """Below this many cells (rows x columns) the automatic mode stays in one process."""
61
+
62
+ # types
63
+ column_types: dict[str, str] = field(default_factory=dict)
64
+ """Types set by hand, e.g. ``{"cylinders": "numeric_discrete", "region": "categorical"}``: they replace
65
+ the decision tree for those columns (numeric_continuous, numeric_discrete, categorical, ordinal-candidate,
66
+ id or text)."""
67
+ id_unique_ratio: float = 0.95
68
+ pattern_match_ratio: float = 0.9
69
+ ordinal_max_levels: int = 12
70
+ ordinal_max_int_levels: int = 7
71
+ discrete_max_levels: int = 50
72
+ text_min_avg_words: float = 3.0
73
+ text_min_avg_length: float = 40.0
74
+
75
+ # stats
76
+ top_n: int = 10
77
+ rare_category_pct: float = 1.0
78
+ max_categories: int = 50
79
+ trim_fraction: float = 0.1
80
+
81
+ # distributions
82
+ alpha: float = 0.05
83
+ candidates_continuous: tuple[str, ...] = CONTINUOUS_CANDIDATES
84
+ candidates_discrete: tuple[str, ...] = DISCRETE_CANDIDATES
85
+ fit_timeout: float = 5.0
86
+ """Seconds allowed per candidate fit (enforced on POSIX main thread)."""
87
+ n_best_fits: int = 3
88
+
89
+ # correlations
90
+ corr_threshold: float = 0.5
91
+ """Pairs with |association| above this are listed as strong."""
92
+ high_corr: float = 0.95
93
+ """Pairs above this are flagged as redundant."""
94
+ leakage_threshold: float = 0.98
95
+ vif_threshold: float = 10.0
96
+ """Variance inflation factor above which a column is flagged as multicollinear."""
97
+
98
+ # quality
99
+ high_missing_pct: float = 50.0
100
+ medium_missing_pct: float = 20.0
101
+ quasi_constant_pct: float = 99.0
102
+ row_missing_pct: float = 50.0
103
+ co_missing_jaccard: float = 0.8
104
+ outlier_alert_pct: float = 5.0
105
+ placeholder_strings: tuple[str, ...] = PLACEHOLDER_STRINGS
106
+ placeholder_numbers: tuple[float, ...] = PLACEHOLDER_NUMBERS
107
+
108
+ # plots
109
+ plot_top_n: int = 15
110
+ n_scatter: int = 4
111
+ n_target_plots: int = 8
112
+ dpi: int = 144
113
+ single_file_report: bool = False
114
+ """Also write ``report_single.html`` with every chart inlined (one file to send or attach)."""