tabint 0.1.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.
- tabint/__init__.py +16 -0
- tabint/_ducktable.py +264 -0
- tabint/_serialize.py +59 -0
- tabint/analytics/__init__.py +4 -0
- tabint/analytics/_prep.py +141 -0
- tabint/analytics/association.py +451 -0
- tabint/analytics/basket.py +135 -0
- tabint/analytics/causal.py +182 -0
- tabint/analytics/clustering.py +180 -0
- tabint/analytics/cohort.py +238 -0
- tabint/analytics/compare.py +109 -0
- tabint/analytics/descriptive.py +236 -0
- tabint/analytics/dimreduction.py +107 -0
- tabint/analytics/feature_computation.py +434 -0
- tabint/analytics/insights.py +126 -0
- tabint/analytics/interpretation.py +189 -0
- tabint/analytics/supervised.py +360 -0
- tabint/analytics/timeseries.py +300 -0
- tabint/cli.py +518 -0
- tabint/connectors/__init__.py +11 -0
- tabint/connectors/base.py +57 -0
- tabint/connectors/contract.py +81 -0
- tabint/connectors/stripe.py +184 -0
- tabint/entitlement.py +138 -0
- tabint/honesty.py +121 -0
- tabint/identity.py +105 -0
- tabint/jobs/__init__.py +1 -0
- tabint/jobs/registry.py +89 -0
- tabint/jobs/runner.py +48 -0
- tabint/loader.py +22 -0
- tabint/mcp_server.py +1162 -0
- tabint/persistence.py +167 -0
- tabint/platform.py +159 -0
- tabint/relationships.py +208 -0
- tabint/results.py +30 -0
- tabint/scratchpad.py +68 -0
- tabint/session.py +256 -0
- tabint/store.py +171 -0
- tabint/validation/__init__.py +1 -0
- tabint/validation/assumptions.py +116 -0
- tabint/validation/dtypes.py +102 -0
- tabint/workspace.py +540 -0
- tabint-0.1.1.dist-info/METADATA +499 -0
- tabint-0.1.1.dist-info/RECORD +48 -0
- tabint-0.1.1.dist-info/WHEEL +5 -0
- tabint-0.1.1.dist-info/entry_points.txt +5 -0
- tabint-0.1.1.dist-info/licenses/LICENSE +201 -0
- tabint-0.1.1.dist-info/top_level.txt +1 -0
tabint/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""tabint — a deterministic, reproducible intelligence layer for single-table data.
|
|
2
|
+
|
|
3
|
+
Public API is intentionally small. See docs/architecture.md for the design and
|
|
4
|
+
docs/roadmap.md for what's implemented vs. planned.
|
|
5
|
+
"""
|
|
6
|
+
from tabint.session import Session
|
|
7
|
+
from tabint.results import Result
|
|
8
|
+
from tabint.workspace import Workspace, Table
|
|
9
|
+
from tabint.relationships import RelationshipGraph, Relationship
|
|
10
|
+
from tabint import persistence
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Session", "Result", "Workspace", "Table",
|
|
14
|
+
"RelationshipGraph", "Relationship", "persistence",
|
|
15
|
+
]
|
|
16
|
+
__version__ = "0.1.0"
|
tabint/_ducktable.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Low-level DuckDB table mechanics shared by Store and Workspace/Table.
|
|
2
|
+
|
|
3
|
+
Both the single-table Store and the multi-table Workspace need the same three
|
|
4
|
+
primitives: load a CSV into a table stamped with a stable row id, read it back in
|
|
5
|
+
that order, and write a computed column back by position. Keeping that logic here
|
|
6
|
+
means there is exactly one implementation of the ``_ti_row`` machinery.
|
|
7
|
+
|
|
8
|
+
Every table is stored as:
|
|
9
|
+
- an internal table ``<internal>`` carrying a 0-based ``_ti_row`` id column
|
|
10
|
+
- a view ``<view>`` = the internal table minus ``_ti_row`` (what callers query)
|
|
11
|
+
|
|
12
|
+
All identifiers (table, view, column names) are double-quoted before being spliced
|
|
13
|
+
into SQL, so names containing spaces, punctuation, or reserved words are handled
|
|
14
|
+
safely; the CSV path is single-quote-escaped for the same reason.
|
|
15
|
+
|
|
16
|
+
All functions take an ibis DuckDB backend; ``backend.con`` is the underlying
|
|
17
|
+
duckdb connection used for the low-level writes ibis doesn't cover.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def quote_ident(name: str) -> str:
|
|
29
|
+
"""Quote a SQL identifier, escaping embedded double quotes."""
|
|
30
|
+
return '"' + str(name).replace('"', '""') + '"'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _quote_literal(value: str) -> str:
|
|
34
|
+
"""Escape a value for use inside a single-quoted SQL string literal."""
|
|
35
|
+
return value.replace("'", "''")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_csv(backend: Any, csv_path: Path, internal: str, view: str) -> None:
|
|
39
|
+
"""Create the internal table (with _ti_row) and its public view from a CSV."""
|
|
40
|
+
path_literal = _quote_literal(str(csv_path))
|
|
41
|
+
create_from_query(backend, f"SELECT * FROM read_csv_auto('{path_literal}')", internal, view)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_from_query(backend: Any, select_sql: str, internal: str, view: str) -> None:
|
|
45
|
+
"""Materialize a SELECT as a new internal table (with _ti_row) plus its view.
|
|
46
|
+
|
|
47
|
+
Used for CSV loads and for derived tables such as joins — anything that
|
|
48
|
+
produces rows and needs the same stable-row-id + hidden-view treatment.
|
|
49
|
+
"""
|
|
50
|
+
qi, qv = quote_ident(internal), quote_ident(view)
|
|
51
|
+
con = backend.con
|
|
52
|
+
con.execute(
|
|
53
|
+
f"CREATE TABLE {qi} AS "
|
|
54
|
+
f"SELECT row_number() OVER () - 1 AS _ti_row, * FROM ({select_sql})"
|
|
55
|
+
)
|
|
56
|
+
con.execute(f"CREATE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_empty(backend: Any, columns: list[tuple[str, str]], internal: str, view: str) -> None:
|
|
60
|
+
"""Create an empty structured table (with auto-assigned _ti_row) plus its view.
|
|
61
|
+
|
|
62
|
+
Unlike create_from_query, this table starts with no rows: it defines a schema
|
|
63
|
+
the caller then fills with insert_select. A DuckDB sequence supplies _ti_row
|
|
64
|
+
so ordinary INSERTs that omit it get a monotonically increasing id for free,
|
|
65
|
+
keeping the table compatible with the frame_in_order / write_back machinery.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
columns: (name, sql_type) pairs. Types must already be validated.
|
|
69
|
+
"""
|
|
70
|
+
qi, qv = quote_ident(internal), quote_ident(view)
|
|
71
|
+
seq = quote_ident(f"_seq_{internal}")
|
|
72
|
+
seq_literal = _quote_literal(f"_seq_{internal}")
|
|
73
|
+
con = backend.con
|
|
74
|
+
coldefs = ", ".join(f"{quote_ident(name)} {sql_type}" for name, sql_type in columns)
|
|
75
|
+
con.execute(f"CREATE SEQUENCE {seq}")
|
|
76
|
+
con.execute(
|
|
77
|
+
f"CREATE TABLE {qi} (_ti_row BIGINT DEFAULT nextval('{seq_literal}'), {coldefs})"
|
|
78
|
+
)
|
|
79
|
+
con.execute(f"CREATE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def insert_select(backend: Any, internal: str, source_sql: str) -> int:
|
|
83
|
+
"""Append rows into an existing table from a SELECT/VALUES query; return the count.
|
|
84
|
+
|
|
85
|
+
The column list (every column except the auto-assigned _ti_row) is spliced in
|
|
86
|
+
explicitly so source_sql maps positionally to the table's own columns and the
|
|
87
|
+
sequence default fills _ti_row. Returns the number of rows inserted.
|
|
88
|
+
"""
|
|
89
|
+
qi = quote_ident(internal)
|
|
90
|
+
con = backend.con
|
|
91
|
+
cols = [row[0] for row in con.execute(f"DESCRIBE {qi}").fetchall() if row[0] != "_ti_row"]
|
|
92
|
+
col_list = ", ".join(quote_ident(c) for c in cols)
|
|
93
|
+
before = con.execute(f"SELECT COUNT(*) FROM {qi}").fetchone()[0]
|
|
94
|
+
con.execute(f"INSERT INTO {qi} ({col_list}) {source_sql}")
|
|
95
|
+
after = con.execute(f"SELECT COUNT(*) FROM {qi}").fetchone()[0]
|
|
96
|
+
return int(after - before)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Persistent registry of derived (computed) columns — cluster labels, outlier
|
|
100
|
+
# flags, predictions, etc. — that must be excluded from feature matrices. It
|
|
101
|
+
# lives in the same DuckDB file as the data so it survives reopen/_reattach; keyed
|
|
102
|
+
# by (table_key, column_name) where table_key is the public table/view name.
|
|
103
|
+
_DERIVED_REGISTRY = "_ti_derived_columns"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ensure_derived_registry(con: Any) -> None:
|
|
107
|
+
con.execute(
|
|
108
|
+
f"CREATE TABLE IF NOT EXISTS {quote_ident(_DERIVED_REGISTRY)} "
|
|
109
|
+
f"(table_key VARCHAR, column_name VARCHAR)"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def register_derived(backend: Any, table_key: str, column_name: str) -> None:
|
|
114
|
+
"""Mark a column as derived (a non-feature annotation) for the given table."""
|
|
115
|
+
con = backend.con
|
|
116
|
+
q = quote_ident(_DERIVED_REGISTRY)
|
|
117
|
+
_ensure_derived_registry(con)
|
|
118
|
+
con.execute(
|
|
119
|
+
f"DELETE FROM {q} WHERE table_key = ? AND column_name = ?", [table_key, column_name]
|
|
120
|
+
)
|
|
121
|
+
con.execute(f"INSERT INTO {q} VALUES (?, ?)", [table_key, column_name])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def unregister_derived(backend: Any, table_key: str, column_name: str) -> None:
|
|
125
|
+
"""Clear any derived mark on a column (e.g. when it is (re)written as a feature)."""
|
|
126
|
+
con = backend.con
|
|
127
|
+
_ensure_derived_registry(con)
|
|
128
|
+
con.execute(
|
|
129
|
+
f"DELETE FROM {quote_ident(_DERIVED_REGISTRY)} "
|
|
130
|
+
f"WHERE table_key = ? AND column_name = ?",
|
|
131
|
+
[table_key, column_name],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def derived_columns(backend: Any, table_key: str) -> set[str]:
|
|
136
|
+
"""Return the set of columns marked derived for the given table."""
|
|
137
|
+
con = backend.con
|
|
138
|
+
_ensure_derived_registry(con)
|
|
139
|
+
rows = con.execute(
|
|
140
|
+
f"SELECT column_name FROM {quote_ident(_DERIVED_REGISTRY)} WHERE table_key = ?",
|
|
141
|
+
[table_key],
|
|
142
|
+
).fetchall()
|
|
143
|
+
return {r[0] for r in rows}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Tokens that must never appear in a feature expression: statement/DDL keywords,
|
|
147
|
+
# subquery starters, and functions that reach the filesystem or catalog. Matched
|
|
148
|
+
# as whole words, case-insensitively — the guard for agent-authored SQL that runs
|
|
149
|
+
# server-side over massive tables (see add_computed_column).
|
|
150
|
+
_SQL_EXPR_BLOCKLIST = frozenset({
|
|
151
|
+
"select", "insert", "update", "delete", "drop", "alter", "create", "attach",
|
|
152
|
+
"detach", "copy", "install", "load", "pragma", "call", "export", "import",
|
|
153
|
+
"set", "reset", "read_csv", "read_csv_auto", "read_parquet", "read_json",
|
|
154
|
+
"read_json_auto", "read_text", "read_blob", "glob", "system", "getvariable",
|
|
155
|
+
})
|
|
156
|
+
_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validate_sql_expression(backend: Any, view: str, expression: str) -> None:
|
|
160
|
+
"""Reject anything beyond a single scalar expression over the table's columns.
|
|
161
|
+
|
|
162
|
+
Layered guard: no statement chaining (``;``), no blocklisted keywords/functions
|
|
163
|
+
(subqueries, DDL/DML, file & catalog readers), then a zero-row bind check so
|
|
164
|
+
the expression must parse as a scalar over the real columns. Raises ValueError.
|
|
165
|
+
"""
|
|
166
|
+
if not expression or not expression.strip():
|
|
167
|
+
raise ValueError("Feature expression is empty.")
|
|
168
|
+
if ";" in expression:
|
|
169
|
+
raise ValueError("Feature expression must be a single expression (no ';').")
|
|
170
|
+
lowered_words = {w.lower() for w in _WORD_RE.findall(expression)}
|
|
171
|
+
hit = lowered_words & _SQL_EXPR_BLOCKLIST
|
|
172
|
+
if hit:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Feature expression may not use {sorted(hit)} — only a scalar computation "
|
|
175
|
+
f"over the table's columns is allowed (no subqueries, DDL, or file access)."
|
|
176
|
+
)
|
|
177
|
+
# Bind-only check: WHERE false computes nothing but still parses/binds the
|
|
178
|
+
# expression and its column references. Cheap even on a huge table.
|
|
179
|
+
qv = quote_ident(view)
|
|
180
|
+
try:
|
|
181
|
+
backend.con.execute(f"SELECT ({expression}) FROM {qv} WHERE false")
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise ValueError(f"Invalid feature expression: {exc}") from exc
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def add_computed_column(backend: Any, internal: str, view: str, name: str, expression: str) -> int:
|
|
187
|
+
"""Add (or replace) a column computed by a SQL scalar expression, in-database.
|
|
188
|
+
|
|
189
|
+
Rebuilds the internal table with the expression as a new column and refreshes
|
|
190
|
+
the public view, preserving ``_ti_row``. Runs entirely inside DuckDB — no rows
|
|
191
|
+
are materialized in the app. Returns the count of non-null values produced.
|
|
192
|
+
"""
|
|
193
|
+
_validate_sql_expression(backend, view, expression)
|
|
194
|
+
con = backend.con
|
|
195
|
+
qi, qv, qn = quote_ident(internal), quote_ident(view), quote_ident(name)
|
|
196
|
+
existing = {row[0] for row in con.execute(f"DESCRIBE {qi}").fetchall()}
|
|
197
|
+
keep = f"* EXCLUDE ({qn})" if name in existing else "*"
|
|
198
|
+
con.execute(f"CREATE OR REPLACE TABLE {qi} AS SELECT {keep}, ({expression}) AS {qn} FROM {qi}")
|
|
199
|
+
con.execute(f"CREATE OR REPLACE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
|
|
200
|
+
return int(con.execute(f"SELECT COUNT({qn}) FROM {qi}").fetchone()[0])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def count_rows(backend: Any, view: str) -> int:
|
|
204
|
+
"""Return the table's row count via an in-database COUNT(*) (no materialization)."""
|
|
205
|
+
return int(backend.con.execute(f"SELECT COUNT(*) FROM {quote_ident(view)}").fetchone()[0])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def count_non_null(backend: Any, view: str, column: str) -> int:
|
|
209
|
+
"""Return the count of non-NULL values in a column, computed inside DuckDB.
|
|
210
|
+
|
|
211
|
+
Raises KeyError if the column does not exist (COUNT(col) counts non-NULLs).
|
|
212
|
+
"""
|
|
213
|
+
con = backend.con
|
|
214
|
+
qv = quote_ident(view)
|
|
215
|
+
existing = {row[0] for row in con.execute(f"DESCRIBE {qv}").fetchall()}
|
|
216
|
+
if column not in existing:
|
|
217
|
+
raise KeyError(f"No column {column!r}. Known: {sorted(existing)}")
|
|
218
|
+
return int(con.execute(f"SELECT COUNT({quote_ident(column)}) FROM {qv}").fetchone()[0])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def frame_in_order(backend: Any, internal: str) -> Any:
|
|
222
|
+
"""Return the table as a pandas DataFrame in stable _ti_row order (id excluded)."""
|
|
223
|
+
return backend.sql(
|
|
224
|
+
f"SELECT * EXCLUDE (_ti_row) FROM {quote_ident(internal)} ORDER BY _ti_row"
|
|
225
|
+
).execute()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def write_back(backend: Any, internal: str, view: str, name: str, values: Any) -> None:
|
|
229
|
+
"""Add or replace a column by position (join on _ti_row), then refresh the view.
|
|
230
|
+
|
|
231
|
+
``values[i]`` is written to the row whose ``_ti_row`` is ``i`` — the same
|
|
232
|
+
order frame_in_order returns, so per-row arrays align without realignment.
|
|
233
|
+
|
|
234
|
+
Raises ValueError if len(values) != the table's row count, since a mismatch
|
|
235
|
+
would silently drop rows through the positional inner join.
|
|
236
|
+
"""
|
|
237
|
+
# DuckDB parameter binding can't consume numpy generics — coerce to Python.
|
|
238
|
+
col_list = [v.item() if isinstance(v, np.generic) else v for v in values]
|
|
239
|
+
n = len(col_list)
|
|
240
|
+
con = backend.con
|
|
241
|
+
qi, qv, qn = quote_ident(internal), quote_ident(view), quote_ident(name)
|
|
242
|
+
tmp = quote_ident(f"_wb_{internal}")
|
|
243
|
+
|
|
244
|
+
current = con.execute(f"SELECT COUNT(*) FROM {qi}").fetchone()[0]
|
|
245
|
+
if n != current:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"write_back_column expected {current} values (one per row) but got {n}."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
con.execute(
|
|
251
|
+
f"CREATE OR REPLACE TEMP TABLE {tmp} AS "
|
|
252
|
+
f"SELECT unnest(range({n})) AS _ti_row, unnest(?) AS {qn}",
|
|
253
|
+
[col_list],
|
|
254
|
+
)
|
|
255
|
+
existing = {row[0] for row in con.execute(f"DESCRIBE {qi}").fetchall()}
|
|
256
|
+
src_cols = f"{qi}.* EXCLUDE ({qn})" if name in existing else f"{qi}.*"
|
|
257
|
+
|
|
258
|
+
con.execute(
|
|
259
|
+
f"CREATE OR REPLACE TABLE {qi} AS "
|
|
260
|
+
f"SELECT {src_cols}, {tmp}.{qn} "
|
|
261
|
+
f"FROM {qi} JOIN {tmp} ON {qi}._ti_row = {tmp}._ti_row"
|
|
262
|
+
)
|
|
263
|
+
con.execute(f"CREATE OR REPLACE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
|
|
264
|
+
con.execute(f"DROP TABLE IF EXISTS {tmp}")
|
tabint/_serialize.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared JSON serialization for the CLI and MCP surfaces.
|
|
2
|
+
|
|
3
|
+
Both front-ends must turn Result objects (and arbitrary values/metadata dicts that
|
|
4
|
+
may contain numpy scalars or NaN) into strict-JSON-safe structures. Keeping that in
|
|
5
|
+
one place means the two surfaces never diverge on how a result is rendered.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import math
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def jsonable(obj: Any) -> Any:
|
|
18
|
+
"""Recursively coerce numpy scalars, NaN, and ±Infinity into JSON-safe values.
|
|
19
|
+
|
|
20
|
+
NaN and infinities are not valid JSON (RFC 8259) and are rejected by strict
|
|
21
|
+
parsers (Node, jq, Go, Rust), so they become null — an agent must never get a
|
|
22
|
+
``0``-exit success that it then fails to parse.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(obj, dict):
|
|
25
|
+
return {k: jsonable(v) for k, v in obj.items()}
|
|
26
|
+
if isinstance(obj, (list, tuple)):
|
|
27
|
+
return [jsonable(v) for v in obj]
|
|
28
|
+
if isinstance(obj, np.generic):
|
|
29
|
+
obj = obj.item()
|
|
30
|
+
# DuckDB DECIMAL columns come back as Python Decimal, and DATE/TIMESTAMP as
|
|
31
|
+
# datetime objects — none are JSON-serializable, so coerce them here.
|
|
32
|
+
if isinstance(obj, Decimal):
|
|
33
|
+
obj = float(obj)
|
|
34
|
+
elif isinstance(obj, (_dt.date, _dt.datetime, _dt.time)):
|
|
35
|
+
return obj.isoformat()
|
|
36
|
+
if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
|
|
37
|
+
return None
|
|
38
|
+
return obj
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def result_dict(res: Any) -> dict:
|
|
42
|
+
"""Render a Result as a plain JSON dict (dropping any non-serializable artifact).
|
|
43
|
+
|
|
44
|
+
Always emits the honesty envelope — a ``trust`` block and a top-level
|
|
45
|
+
``declined`` flag — so every tool's output carries confidence uniformly. An
|
|
46
|
+
analytic that hasn't set trust reports ``unassessed`` (never a fake ``high``).
|
|
47
|
+
"""
|
|
48
|
+
from . import honesty
|
|
49
|
+
|
|
50
|
+
trust = getattr(res, "trust", None) or honesty.unassessed()
|
|
51
|
+
trust_json = trust.model_dump(mode="json")
|
|
52
|
+
return {
|
|
53
|
+
"method": res.method,
|
|
54
|
+
"summary": res.summary,
|
|
55
|
+
"trust": trust_json,
|
|
56
|
+
"declined": bool(trust_json.get("declined", False)),
|
|
57
|
+
"values": jsonable(res.values),
|
|
58
|
+
"metadata": jsonable(res.metadata),
|
|
59
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""Analytics families: descriptive, association, clustering, supervised, interpretation,
|
|
2
|
+
dimensionality reduction, time series, feature-computation (generic feature
|
|
3
|
+
engineering), and the insight-extraction primitives (insights/key-drivers,
|
|
4
|
+
basket/market-basket, causal, cohort/RFM+retention, compare)."""
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Shared feature-preparation helpers for the model-based families.
|
|
2
|
+
|
|
3
|
+
Clustering, dimensionality reduction, and supervised learning all need to turn
|
|
4
|
+
the stored table into a numeric, model-ready matrix. Doing that consistently —
|
|
5
|
+
same column selection, same encoding, same imputation — is what keeps results
|
|
6
|
+
comparable across families, so it lives here rather than being re-derived in each
|
|
7
|
+
module.
|
|
8
|
+
|
|
9
|
+
Column *classification* is never re-decided here; it is delegated to
|
|
10
|
+
validation.dtypes (the single source of truth). This module only decides how a
|
|
11
|
+
classified column is fed to scikit-learn.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from sklearn.compose import ColumnTransformer
|
|
19
|
+
from sklearn.impute import SimpleImputer
|
|
20
|
+
from sklearn.pipeline import Pipeline
|
|
21
|
+
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
|
22
|
+
|
|
23
|
+
from ..validation.dtypes import classify_column
|
|
24
|
+
|
|
25
|
+
# Column types (from validation.dtypes) that carry modelling signal and how they
|
|
26
|
+
# should be treated. Identifiers and datetimes are excluded from feature matrices.
|
|
27
|
+
_NUMERIC_TYPES = {"continuous"}
|
|
28
|
+
_CATEGORICAL_TYPES = {"categorical_nominal", "categorical_ordinal"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_frame(store: Any) -> pd.DataFrame:
|
|
32
|
+
"""Return the full table in stable (_ti_row) order as a pandas DataFrame."""
|
|
33
|
+
return store.get_frame()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def feature_columns(
|
|
37
|
+
store: Any,
|
|
38
|
+
exclude: tuple[str, ...] = (),
|
|
39
|
+
) -> tuple[list[str], list[str]]:
|
|
40
|
+
"""Split the table's columns into (numeric, categorical) feature lists.
|
|
41
|
+
|
|
42
|
+
Identifier and datetime columns are dropped — they are not features. Any
|
|
43
|
+
names in ``exclude`` (e.g. the supervised target, or a cluster-label column)
|
|
44
|
+
are dropped too.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
store: The Store instance.
|
|
48
|
+
exclude: Column names to omit from both lists.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
(numeric_columns, categorical_columns).
|
|
52
|
+
"""
|
|
53
|
+
# Derived annotations (outlier flags, cluster labels, predictions) are never
|
|
54
|
+
# features — drop them alongside the caller's explicit excludes so they can't
|
|
55
|
+
# leak into a model. reduce_dimensions marks its components feature=True, so
|
|
56
|
+
# those stay eligible.
|
|
57
|
+
get_derived = getattr(store, "derived_columns", None)
|
|
58
|
+
excluded = set(exclude) | (get_derived() if get_derived else set())
|
|
59
|
+
|
|
60
|
+
numeric: list[str] = []
|
|
61
|
+
categorical: list[str] = []
|
|
62
|
+
for name in store._table.schema():
|
|
63
|
+
if name in excluded:
|
|
64
|
+
continue
|
|
65
|
+
kind = classify_column(name, store)
|
|
66
|
+
if kind in _NUMERIC_TYPES:
|
|
67
|
+
numeric.append(name)
|
|
68
|
+
elif kind in _CATEGORICAL_TYPES:
|
|
69
|
+
categorical.append(name)
|
|
70
|
+
# identifier / datetime → intentionally skipped
|
|
71
|
+
return numeric, categorical
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_preprocessor(
|
|
75
|
+
numeric: list[str],
|
|
76
|
+
categorical: list[str],
|
|
77
|
+
*,
|
|
78
|
+
scale: bool,
|
|
79
|
+
) -> ColumnTransformer:
|
|
80
|
+
"""Build a ColumnTransformer that imputes, (optionally) scales, and one-hot encodes.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
numeric: Numeric (continuous) feature column names.
|
|
84
|
+
categorical: Categorical feature column names.
|
|
85
|
+
scale: Whether to standard-scale numeric columns. Distance-based methods
|
|
86
|
+
(k-means, PCA) need this; tree ensembles do not.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
An unfitted ColumnTransformer.
|
|
90
|
+
"""
|
|
91
|
+
numeric_steps: list[tuple[str, Any]] = [("impute", SimpleImputer(strategy="median"))]
|
|
92
|
+
if scale:
|
|
93
|
+
numeric_steps.append(("scale", StandardScaler()))
|
|
94
|
+
numeric_pipe = Pipeline(numeric_steps)
|
|
95
|
+
|
|
96
|
+
categorical_pipe = Pipeline([
|
|
97
|
+
("impute", SimpleImputer(strategy="most_frequent")),
|
|
98
|
+
# Dense output: HistGradientBoosting and the manifold methods reject sparse.
|
|
99
|
+
("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
transformers = []
|
|
103
|
+
if numeric:
|
|
104
|
+
transformers.append(("numeric", numeric_pipe, numeric))
|
|
105
|
+
if categorical:
|
|
106
|
+
transformers.append(("categorical", categorical_pipe, categorical))
|
|
107
|
+
return ColumnTransformer(transformers, remainder="drop")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def numeric_matrix(
|
|
111
|
+
store: Any,
|
|
112
|
+
exclude: tuple[str, ...] = (),
|
|
113
|
+
*,
|
|
114
|
+
scale: bool = True,
|
|
115
|
+
) -> tuple[Any, pd.DataFrame, list[str]]:
|
|
116
|
+
"""Materialize a fully numeric feature matrix for distance-based methods.
|
|
117
|
+
|
|
118
|
+
Used by clustering and dimensionality reduction. Numeric columns are scaled
|
|
119
|
+
(by default) and categorical columns one-hot encoded.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
store: The Store instance.
|
|
123
|
+
exclude: Column names to omit (e.g. an existing cluster-label column).
|
|
124
|
+
scale: Whether to standard-scale numeric columns.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
(X, frame, feature_names) where X is the transformed 2-D array in stable
|
|
128
|
+
row order, frame is the source DataFrame, and feature_names are the
|
|
129
|
+
original feature column names (pre-encoding).
|
|
130
|
+
"""
|
|
131
|
+
frame = get_frame(store)
|
|
132
|
+
numeric, categorical = feature_columns(store, exclude=exclude)
|
|
133
|
+
if not numeric and not categorical:
|
|
134
|
+
raise ValueError("No usable feature columns (all identifier/datetime).")
|
|
135
|
+
pre = build_preprocessor(numeric, categorical, scale=scale)
|
|
136
|
+
X = pre.fit_transform(frame)
|
|
137
|
+
# Densify sparse one-hot output so downstream estimators that dislike sparse
|
|
138
|
+
# input (e.g. some sklearn manifold methods) work uniformly.
|
|
139
|
+
if hasattr(X, "toarray"):
|
|
140
|
+
X = X.toarray()
|
|
141
|
+
return X, frame, numeric + categorical
|