easyclassifier 0.8.1__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,202 @@
1
+ """Choosing and checking the column to predict (the target / class column).
2
+
3
+ Everything here is plain logic with no user interaction, so it can be tested
4
+ directly. The wizard uses it to
5
+
6
+ * describe every column in plain words so the user can pick the right one,
7
+ * suggest the most likely class column,
8
+ * catch unsuitable choices (a single value, an ID column, a measurement
9
+ instead of a category, classes with too few rows), and
10
+ * turn a measurement into categories when the user wants that.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ # Kinds of column
22
+ CATEGORY = "category" # a sensible class column
23
+ MEASUREMENT = "measurement" # numbers with many different values
24
+ ID_LIKE = "id" # different in every row (an identifier)
25
+ CONSTANT = "constant" # a single value, nothing to predict
26
+ MANY_TEXT = "many_text" # text with very many different values
27
+ EMPTY = "empty" # no values at all
28
+
29
+ COMMON_TARGET_NAMES = [
30
+ "target", "label", "class", "outcome", "result", "disease",
31
+ "diagnosis", "purchased", "churn", "y", "category", "species",
32
+ "status", "response", "grade",
33
+ ]
34
+
35
+ MIN_ROWS_PER_CLASS = 2 # fewer than this cannot be tested at all
36
+ FEW_ROWS_PER_CLASS = 5 # fewer than this gives unreliable results
37
+
38
+
39
+ def _max_categories(n_rows: int) -> int:
40
+ """How many different values a class column may reasonably have."""
41
+ return max(20, int(0.05 * n_rows))
42
+
43
+
44
+ def _is_integer_valued(s: pd.Series) -> bool:
45
+ v = s.dropna().to_numpy(dtype=float)
46
+ return bool(len(v)) and bool(np.all(np.equal(np.mod(v, 1), 0)))
47
+
48
+
49
+ def _fmt(v) -> str:
50
+ if isinstance(v, (float, np.floating)) and float(v).is_integer():
51
+ return str(int(v))
52
+ if isinstance(v, (float, np.floating)):
53
+ return f"{v:.4g}"
54
+ return str(v)
55
+
56
+
57
+ @dataclass
58
+ class ColumnInfo:
59
+ name: str
60
+ kind: str
61
+ n_unique: int
62
+ n_values: int
63
+ label: str # one-line plain-language description
64
+ counts: Dict[str, int] = field(default_factory=dict)
65
+
66
+
67
+ def describe_column(s: pd.Series) -> ColumnInfo:
68
+ """Classify a column and describe it in plain words."""
69
+ name = str(s.name)
70
+ vals = s.dropna()
71
+ n, u = len(vals), int(vals.nunique())
72
+ numeric = (pd.api.types.is_numeric_dtype(s)
73
+ and not pd.api.types.is_bool_dtype(s))
74
+
75
+ if n == 0:
76
+ return ColumnInfo(name, EMPTY, 0, 0, f"{name} - empty column")
77
+ if u == 1:
78
+ return ColumnInfo(name, CONSTANT, 1, n,
79
+ f"{name} - only one value ({_fmt(vals.iloc[0])})")
80
+
81
+ looks_like_id = "id" in name.lower().replace("_", " ").split() or \
82
+ name.lower().endswith("id")
83
+ if u == n and n >= 20:
84
+ consecutive = (numeric and _is_integer_valued(vals)
85
+ and vals.max() - vals.min() + 1 == n)
86
+ if not numeric or consecutive or looks_like_id:
87
+ return ColumnInfo(name, ID_LIKE, u, n,
88
+ f"{name} - different in every row "
89
+ "(looks like an ID)")
90
+
91
+ if numeric and u > 2:
92
+ integer = _is_integer_valued(vals)
93
+ mostly_distinct = u > 10 and u > n / 2
94
+ if (not integer and u > 10) or mostly_distinct \
95
+ or u > _max_categories(n):
96
+ return ColumnInfo(
97
+ name, MEASUREMENT, u, n,
98
+ f"{name} - numbers from {_fmt(vals.min())} to "
99
+ f"{_fmt(vals.max())} ({u} different)")
100
+
101
+ if not numeric and u > _max_categories(n):
102
+ return ColumnInfo(name, MANY_TEXT, u, n,
103
+ f"{name} - {u} different text values")
104
+
105
+ counts = vals.value_counts()
106
+ shown = ", ".join(_fmt(v) for v in counts.index[:3])
107
+ more = ", ..." if u > 3 else ""
108
+ return ColumnInfo(name, CATEGORY, u, n,
109
+ f"{name} - {u} categories ({shown}{more})",
110
+ counts={_fmt(k): int(v) for k, v in counts.items()})
111
+
112
+
113
+ def suggest_target(df: pd.DataFrame) -> Optional[str]:
114
+ """Most likely class column, or None if nothing looks suitable."""
115
+ infos = {c: describe_column(df[c]) for c in df.columns}
116
+ lower = {str(c).lower(): c for c in df.columns}
117
+ for name in COMMON_TARGET_NAMES:
118
+ c = lower.get(name)
119
+ if c is not None and infos[c].kind == CATEGORY:
120
+ return c
121
+ # Otherwise the last category-like column (class columns are usually
122
+ # last), preferring few categories.
123
+ cands = [c for c in df.columns if infos[c].kind == CATEGORY]
124
+ if not cands:
125
+ return None
126
+ return min(reversed(cands), key=lambda c: infos[c].n_unique)
127
+
128
+
129
+ # --------------------------------------------------------------------------- #
130
+ # Class-size checks
131
+ # --------------------------------------------------------------------------- #
132
+
133
+ def tiny_and_small_classes(s: pd.Series) -> Tuple[List[str], List[str]]:
134
+ """Classes with fewer than 2 rows (unusable) and fewer than 5 (risky)."""
135
+ counts = s.dropna().astype(str).value_counts()
136
+ tiny = [k for k, v in counts.items() if v < MIN_ROWS_PER_CLASS]
137
+ small = [k for k, v in counts.items()
138
+ if MIN_ROWS_PER_CLASS <= v < FEW_ROWS_PER_CLASS]
139
+ return tiny, small
140
+
141
+
142
+ # --------------------------------------------------------------------------- #
143
+ # Turning a measurement into categories
144
+ # --------------------------------------------------------------------------- #
145
+
146
+ GROUP_NAMES = {
147
+ 2: ["Low", "High"],
148
+ 3: ["Low", "Medium", "High"],
149
+ 4: ["Very low", "Low", "High", "Very high"],
150
+ }
151
+
152
+
153
+ def group_equal(s: pd.Series, k: int) -> Tuple[pd.Series, List[float], str]:
154
+ """Split numbers into ``k`` groups of (roughly) equal size.
155
+
156
+ Returns the new labels, the cut-points, and a description. If many rows
157
+ share the same value, fewer groups may result.
158
+ """
159
+ vals = pd.to_numeric(s, errors="coerce")
160
+ uniq = np.sort(vals.dropna().unique())
161
+ cuts = []
162
+ for q in vals.quantile([i / k for i in range(1, k)]).tolist():
163
+ # A value >= cut goes to the higher group. A cut at (or below) the
164
+ # smallest value would leave the lowest group empty, so move it to
165
+ # the next value up - e.g. many zeros give "0" vs "above 0".
166
+ above = uniq[uniq > q] if q <= uniq[0] else uniq[uniq >= q]
167
+ if len(above):
168
+ cuts.append(float(above[0]))
169
+ cuts = sorted(set(cuts))
170
+ if not cuts:
171
+ raise ValueError("all values are the same; cannot form groups")
172
+ return _cut(vals, cuts)
173
+
174
+
175
+ def group_at(s: pd.Series, threshold: float) -> Tuple[pd.Series, List[float], str]:
176
+ """Two groups: below the threshold, and at or above it."""
177
+ vals = pd.to_numeric(s, errors="coerce")
178
+ return _cut(vals, [float(threshold)])
179
+
180
+
181
+ def _cut(vals: pd.Series, cuts: List[float]) -> Tuple[pd.Series, List[float], str]:
182
+ cuts = sorted(set(float(c) for c in cuts))
183
+ k = len(cuts) + 1
184
+ names = GROUP_NAMES.get(k, [f"Group {i + 1}" for i in range(k)])
185
+ labels = []
186
+ for i in range(k):
187
+ if i == 0:
188
+ rng = f"< {_fmt(cuts[0])}"
189
+ elif i == k - 1:
190
+ rng = f">= {_fmt(cuts[-1])}"
191
+ else:
192
+ rng = f"{_fmt(cuts[i - 1])} to < {_fmt(cuts[i])}"
193
+ labels.append(f"{names[i]} ({rng})")
194
+ codes = np.searchsorted(np.asarray(cuts), vals.to_numpy(dtype=float),
195
+ side="right")
196
+ # An ordered category keeps Low < Medium < High in tables and figures.
197
+ out = pd.Series(pd.Categorical(
198
+ [labels[c] if not np.isnan(v) else np.nan
199
+ for c, v in zip(codes, vals.to_numpy(dtype=float))],
200
+ categories=labels, ordered=True), index=vals.index, name=vals.name)
201
+ desc = "; ".join(labels)
202
+ return out, cuts, desc
easyclassifier/ui.py ADDED
@@ -0,0 +1,281 @@
1
+ """Terminal user-interface helpers for EasyClassifier.
2
+
3
+ All user interaction goes through these functions so the rest of the code
4
+ never touches ``input``/``print`` directly. This keeps the wizard logic clean
5
+ and makes the interface easy to swap out later (e.g. for a GUI).
6
+
7
+ The helpers are intentionally plain-text and dependency-free so they work in
8
+ any terminal.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import textwrap
14
+ from typing import List, Optional, Sequence, Tuple
15
+
16
+
17
+ # --------------------------------------------------------------------------- #
18
+ # Basic output
19
+ # --------------------------------------------------------------------------- #
20
+
21
+ WIDTH = 66
22
+
23
+
24
+ def _line(char: str = "-") -> str:
25
+ return char * WIDTH
26
+
27
+
28
+ def rule(char: str = "-") -> None:
29
+ print(_line(char))
30
+
31
+
32
+ def banner(title: str, subtitle: str = "") -> None:
33
+ """Print a boxed banner."""
34
+ print()
35
+ rule("=")
36
+ print(title.center(WIDTH))
37
+ if subtitle:
38
+ print(subtitle.center(WIDTH))
39
+ rule("=")
40
+
41
+
42
+ def header(text: str) -> None:
43
+ """Section header."""
44
+ print()
45
+ rule()
46
+ print(text)
47
+ rule()
48
+
49
+
50
+ def info(text: str) -> None:
51
+ for line in textwrap.wrap(text, WIDTH) or [""]:
52
+ print(line)
53
+
54
+
55
+ def note(text: str) -> None:
56
+ print(f" * {text}")
57
+
58
+
59
+ def success(text: str) -> None:
60
+ print(f"[OK] {text}")
61
+
62
+
63
+ def warn(text: str) -> None:
64
+ print(f"[!] {text}")
65
+
66
+
67
+ def error(text: str) -> None:
68
+ print(f"[X] {text}")
69
+
70
+
71
+ def blank() -> None:
72
+ print()
73
+
74
+
75
+ def pause(msg: str = "Press ENTER to continue...") -> None:
76
+ try:
77
+ input("\n" + msg)
78
+ except EOFError:
79
+ pass
80
+
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # Input primitives
84
+ # --------------------------------------------------------------------------- #
85
+
86
+ def _read(prompt: str) -> str:
87
+ try:
88
+ return input(prompt)
89
+ except EOFError:
90
+ # Non-interactive / piped input exhausted.
91
+ print()
92
+ raise KeyboardInterrupt
93
+
94
+
95
+ def ask_text(prompt: str, default: Optional[str] = None,
96
+ allow_empty: bool = False) -> str:
97
+ """Free-text input with an optional default."""
98
+ suffix = f" [{default}]" if default is not None else ""
99
+ while True:
100
+ value = _read(f"{prompt}{suffix}\n>> ").strip()
101
+ if not value:
102
+ if default is not None:
103
+ return default
104
+ if allow_empty:
105
+ return ""
106
+ error("Please enter a value.")
107
+ continue
108
+ return value
109
+
110
+
111
+ def ask_yes_no(prompt: str, default: Optional[bool] = None) -> bool:
112
+ """Yes/No question. Returns True for yes."""
113
+ if default is True:
114
+ hint = " [Y/n]"
115
+ elif default is False:
116
+ hint = " [y/N]"
117
+ else:
118
+ hint = " [y/n]"
119
+ while True:
120
+ value = _read(f"{prompt}{hint}\n>> ").strip().lower()
121
+ if not value and default is not None:
122
+ return default
123
+ if value in ("y", "yes", "1"):
124
+ return True
125
+ if value in ("n", "no", "2"):
126
+ return False
127
+ error("Please answer y or n.")
128
+
129
+
130
+ def menu(prompt: str, options: Sequence[str],
131
+ default: Optional[int] = None,
132
+ allow_help: bool = True,
133
+ help_keys: Optional[Sequence[str]] = None) -> int:
134
+ """Numbered single-choice menu.
135
+
136
+ Returns the zero-based index of the chosen option.
137
+ Options are displayed starting at 1. If ``help_keys`` is provided the user
138
+ may type ``?N`` to see help for option N (handled by the caller via the
139
+ returned sentinel).
140
+ """
141
+ print()
142
+ print(prompt)
143
+ for i, opt in enumerate(options, start=1):
144
+ print(f" {i}. {opt}")
145
+ hint = ""
146
+ if default is not None:
147
+ hint = f" (default {default + 1})"
148
+ if allow_help:
149
+ hint += " | type ?N for help on option N"
150
+ while True:
151
+ raw = _read(f"Choose 1-{len(options)}{hint}\n>> ").strip()
152
+ if not raw and default is not None:
153
+ return default
154
+ if allow_help and raw.startswith("?"):
155
+ num = raw[1:].strip()
156
+ if num.isdigit() and 1 <= int(num) <= len(options):
157
+ return -int(num) # sentinel: caller shows help
158
+ error("Type ? followed by a valid option number, e.g. ?2")
159
+ continue
160
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
161
+ return int(raw) - 1
162
+ error(f"Please enter a number between 1 and {len(options)}.")
163
+
164
+
165
+ def multi_select(prompt: str, options: Sequence[str],
166
+ default_all: bool = True,
167
+ default: Optional[List[int]] = None) -> List[int]:
168
+ """Multiple-choice checkbox menu.
169
+
170
+ User enters comma-separated numbers, 'all', or 'none'. ENTER gives
171
+ ``default`` if provided, otherwise all (``default_all``) or none.
172
+ Returns a list of zero-based indices.
173
+ """
174
+ if default is None:
175
+ default = list(range(len(options))) if default_all else []
176
+ print()
177
+ print(prompt)
178
+ for i, opt in enumerate(options, start=1):
179
+ mark = "x" if i - 1 in default else " "
180
+ print(f" [{mark}] {i}. {opt}")
181
+ if len(default) == len(options):
182
+ default_hint = "ENTER = all"
183
+ elif not default:
184
+ default_hint = "ENTER = none"
185
+ else:
186
+ default_hint = "ENTER = " + ",".join(str(i + 1) for i in default)
187
+ while True:
188
+ raw = _read(
189
+ f"Enter numbers separated by commas, or 'all' / 'none' "
190
+ f"[{default_hint}]\n>> "
191
+ ).strip().lower()
192
+ if not raw:
193
+ return list(default)
194
+ if raw == "all":
195
+ return list(range(len(options)))
196
+ if raw == "none":
197
+ return []
198
+ try:
199
+ picks = sorted({
200
+ int(p) - 1 for p in raw.replace(" ", "").split(",") if p != ""
201
+ })
202
+ except ValueError:
203
+ error("Please enter numbers separated by commas.")
204
+ continue
205
+ if all(0 <= p < len(options) for p in picks) and picks:
206
+ return picks
207
+ error(f"Please enter numbers between 1 and {len(options)}.")
208
+
209
+
210
+ def choose_from_list(prompt: str, items: Sequence[str],
211
+ extra_options: Sequence[str] = ()) -> Tuple[int, bool]:
212
+ """Present a list of items plus optional extra menu entries.
213
+
214
+ Returns ``(index, is_extra)``. If ``is_extra`` is True the index refers to
215
+ ``extra_options``; otherwise it refers to ``items``.
216
+ """
217
+ combined = list(items) + list(extra_options)
218
+ idx = menu(prompt, combined, allow_help=False)
219
+ if idx >= len(items):
220
+ return idx - len(items), True
221
+ return idx, False
222
+
223
+
224
+ def pick_from_long_list(prompt: str, labels: Sequence[str],
225
+ names: Sequence[str],
226
+ default: Optional[int] = None,
227
+ marks: Optional[dict] = None,
228
+ page_size: int = 20) -> int:
229
+ """Choose one item from a possibly very long list.
230
+
231
+ Numbers stay the same while paging or searching, so the user can always
232
+ type the number they saw. Typing text shows only the items whose name
233
+ contains that text. Returns the zero-based index into ``labels``.
234
+ """
235
+ marks = marks or {}
236
+ shown = list(range(len(labels)))
237
+ page = 0
238
+ while True:
239
+ pages = max(1, (len(shown) + page_size - 1) // page_size)
240
+ page = min(page, pages - 1)
241
+ print()
242
+ print(prompt)
243
+ visible = shown[page * page_size:(page + 1) * page_size]
244
+ for i in visible:
245
+ print(f" {i + 1:>3}. {labels[i]}{marks.get(i, '')}")
246
+ if default is not None and default not in visible:
247
+ print(f" Suggested: {default + 1}. {labels[default]}")
248
+ hints = []
249
+ if default is not None:
250
+ hints.append(f"ENTER = {default + 1}, the suggested one")
251
+ if pages > 1:
252
+ hints.append(f"page {page + 1} of {pages}: 'n' next, 'p' back")
253
+ if len(labels) > page_size or len(shown) < len(labels):
254
+ hints.append("type part of a name to search"
255
+ + (", 'all' to list everything"
256
+ if len(shown) < len(labels) else ""))
257
+ if hints:
258
+ print(" (" + "; ".join(hints) + ")")
259
+ raw = _read("Choose a number\n>> ").strip()
260
+ low = raw.lower()
261
+ if not raw:
262
+ if default is not None:
263
+ return default
264
+ error("Please type a number.")
265
+ elif raw.isdigit():
266
+ n = int(raw)
267
+ if 1 <= n <= len(labels):
268
+ return n - 1
269
+ error(f"Please enter a number between 1 and {len(labels)}.")
270
+ elif low in ("n", "next"):
271
+ page = page + 1 if page + 1 < pages else page
272
+ elif low in ("p", "back", "prev"):
273
+ page = max(0, page - 1)
274
+ elif low == "all":
275
+ shown, page = list(range(len(labels))), 0
276
+ else:
277
+ found = [i for i, nm in enumerate(names) if low in str(nm).lower()]
278
+ if found:
279
+ shown, page = found, 0
280
+ else:
281
+ error(f"No column name contains '{raw}'.")