signapy 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.
- signapy/__init__.py +14 -0
- signapy/discovery/__init__.py +378 -0
- signapy/discovery/_frame.py +81 -0
- signapy/discovery/feature.py +318 -0
- signapy/discovery/value.py +253 -0
- signapy/metrics/__init__.py +24 -0
- signapy/metrics/association.py +603 -0
- signapy/metrics/lift.py +158 -0
- signapy/metrics/significance.py +106 -0
- signapy/profiling/__init__.py +9 -0
- signapy/profiling/types.py +39 -0
- signapy/py.typed +0 -0
- signapy/results/__init__.py +9 -0
- signapy/results/_plot.py +555 -0
- signapy/results/models.py +409 -0
- signapy-0.1.0.dist-info/METADATA +419 -0
- signapy-0.1.0.dist-info/RECORD +19 -0
- signapy-0.1.0.dist-info/WHEEL +4 -0
- signapy-0.1.0.dist-info/licenses/LICENSE +21 -0
signapy/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""SignaPy: exploratory feature discovery for labeled datasets.
|
|
2
|
+
|
|
3
|
+
SignaPy is pre-1.0, so the API may still change between minor releases.
|
|
4
|
+
``signapy.discover`` currently supports a binary target
|
|
5
|
+
against mixed categorical, continuous, and ordinal features. See
|
|
6
|
+
``docs/architecture.md`` for the design contract and roadmap, and
|
|
7
|
+
``docs/metrics.md`` for how to interpret the evidence it returns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from signapy.discovery import discover
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__", "discover"]
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Discovery workflows: the user-facing layer of SignaPy.
|
|
2
|
+
|
|
3
|
+
This package answers discovery *questions* rather than exposing individual
|
|
4
|
+
statistical tests. It chooses an appropriate method from the feature and
|
|
5
|
+
target types, calls into :mod:`signapy.metrics`, and packages the evidence
|
|
6
|
+
into :mod:`signapy.results` models.
|
|
7
|
+
|
|
8
|
+
Modules:
|
|
9
|
+
|
|
10
|
+
- ``feature``: feature-level discovery for one categorical feature
|
|
11
|
+
(:func:`~signapy.discovery.feature.analyze_categorical_feature`), one
|
|
12
|
+
continuous feature
|
|
13
|
+
(:func:`~signapy.discovery.feature.analyze_continuous_feature`), or one
|
|
14
|
+
ordinal feature
|
|
15
|
+
(:func:`~signapy.discovery.feature.analyze_ordinal_feature`)
|
|
16
|
+
- ``value``: value-level discovery, adapting
|
|
17
|
+
:func:`~signapy.metrics.lift.categorical_lift` to
|
|
18
|
+
:class:`~signapy.results.ValueResult`
|
|
19
|
+
(:func:`~signapy.discovery.value.build_value_results`). Reused by both
|
|
20
|
+
the categorical and ordinal analyzers (ordinal reorders the result to
|
|
21
|
+
its declared category order); for continuous features
|
|
22
|
+
the feature is first cut into adaptive continuous bins
|
|
23
|
+
(:func:`~signapy.discovery.value.build_binned_value_results`) and each bin
|
|
24
|
+
is a value (see ``docs/architecture.md``).
|
|
25
|
+
|
|
26
|
+
This module's :func:`discover` is the top-level entry point, exported as
|
|
27
|
+
``signapy.discover``. It supports categorical, continuous, and ordinal
|
|
28
|
+
features against a binary target; :data:`_DISPATCH` is where support for
|
|
29
|
+
additional ``(FeatureType, TargetType)`` combinations will be added.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Callable, Hashable, Mapping
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
import pandas as pd
|
|
38
|
+
|
|
39
|
+
from signapy.discovery._frame import (
|
|
40
|
+
is_polars_dataframe,
|
|
41
|
+
polars_to_pandas,
|
|
42
|
+
)
|
|
43
|
+
from signapy.discovery.feature import (
|
|
44
|
+
analyze_categorical_feature,
|
|
45
|
+
analyze_continuous_feature,
|
|
46
|
+
analyze_ordinal_feature,
|
|
47
|
+
)
|
|
48
|
+
from signapy.profiling import FeatureType, TargetType
|
|
49
|
+
from signapy.results import DiscoveryReport, FeatureResult
|
|
50
|
+
|
|
51
|
+
__all__ = ["discover"]
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
import polars as pl
|
|
55
|
+
|
|
56
|
+
_FeatureAnalyzer = Callable[..., FeatureResult]
|
|
57
|
+
|
|
58
|
+
# Method dispatch: which analyzer handles a given (declared feature type,
|
|
59
|
+
# target type) combination. Adding a new supported combination (e.g. a
|
|
60
|
+
# continuous target, or boolean features) means adding an entry here and to
|
|
61
|
+
# _SUPPORTED_FEATURE_TYPES/_resolve_feature_type, not a longer if/elif chain.
|
|
62
|
+
_DISPATCH: dict[tuple[FeatureType, TargetType], _FeatureAnalyzer] = {
|
|
63
|
+
(FeatureType.CATEGORICAL, TargetType.BINARY): analyze_categorical_feature,
|
|
64
|
+
(FeatureType.CONTINUOUS, TargetType.BINARY): analyze_continuous_feature,
|
|
65
|
+
(FeatureType.ORDINAL, TargetType.BINARY): analyze_ordinal_feature,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Feature types accepted in the *public* feature_types argument. A stricter
|
|
69
|
+
# (and currently identical) set than _DISPATCH's keys would allow, kept
|
|
70
|
+
# separate so "not a supported FeatureType value here" and "no analyzer for
|
|
71
|
+
# this combination" can be reported as the distinct failures they are.
|
|
72
|
+
_SUPPORTED_FEATURE_TYPES = (
|
|
73
|
+
FeatureType.CATEGORICAL,
|
|
74
|
+
FeatureType.CONTINUOUS,
|
|
75
|
+
FeatureType.ORDINAL,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_supported_categorical_dtype(series: pd.Series) -> bool:
|
|
80
|
+
"""Whether ``series`` has a dtype this discovery slice treats as categorical.
|
|
81
|
+
|
|
82
|
+
Supported: ``object``, pandas ``category``, and pandas ``string``
|
|
83
|
+
dtypes. Numeric dtypes (including integer-coded categories) and
|
|
84
|
+
``bool`` are deliberately unsupported: this slice does not infer
|
|
85
|
+
semantic type from values, so a numeric or boolean column is never
|
|
86
|
+
silently reinterpreted as categorical (see ``docs/architecture.md``).
|
|
87
|
+
"""
|
|
88
|
+
dtype = series.dtype
|
|
89
|
+
return pd.api.types.is_object_dtype(dtype) or isinstance(
|
|
90
|
+
dtype, pd.CategoricalDtype | pd.StringDtype
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_ordinal_dtype(name: str, series: pd.Series) -> None:
|
|
95
|
+
"""Require an ordered pandas ``Categorical`` for a feature declared ORDINAL.
|
|
96
|
+
|
|
97
|
+
SignaPy never infers an order (not from the values, and never
|
|
98
|
+
alphabetically) — the order must be explicit:
|
|
99
|
+
``pd.Categorical(df[name], categories=[...], ordered=True)``.
|
|
100
|
+
|
|
101
|
+
Raises two distinct, actionable errors rather than one generic one,
|
|
102
|
+
since they're different mistakes with different fixes:
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If ``series`` isn't a pandas ``category`` dtype at all
|
|
106
|
+
(the column needs to be built as a ``Categorical`` first), or
|
|
107
|
+
if it is one but ``ordered=False`` (the order declaration is
|
|
108
|
+
missing, not the categorical-ness itself).
|
|
109
|
+
"""
|
|
110
|
+
dtype = series.dtype
|
|
111
|
+
if not isinstance(dtype, pd.CategoricalDtype):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"feature {name!r} has unsupported dtype {dtype}; ordinal "
|
|
114
|
+
"features must be an ordered pandas Categorical — construct it "
|
|
115
|
+
f"with e.g. df[{name!r}] = pd.Categorical(df[{name!r}], "
|
|
116
|
+
"categories=[...], ordered=True) before calling discover(). "
|
|
117
|
+
"signapy never infers a category order from the values, and "
|
|
118
|
+
"never assumes alphabetical order — see docs/architecture.md."
|
|
119
|
+
)
|
|
120
|
+
if not dtype.ordered:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"feature {name!r} is an unordered categorical "
|
|
123
|
+
f"(pd.Categorical(..., ordered=False)); ordinal features need "
|
|
124
|
+
f"an explicit declared order — construct it with "
|
|
125
|
+
f"pd.Categorical(df[{name!r}], categories=[...], ordered=True) "
|
|
126
|
+
"rather than relying on signapy to infer one — see "
|
|
127
|
+
"docs/architecture.md."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_feature_type(name: str, declared: FeatureType | str) -> FeatureType:
|
|
132
|
+
"""Validate and normalize one ``feature_types`` entry.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If ``declared`` is not a valid :class:`FeatureType`
|
|
136
|
+
value, or is a valid one that this discovery slice does not yet
|
|
137
|
+
support (anything other than
|
|
138
|
+
``CATEGORICAL``/``CONTINUOUS``/``ORDINAL``).
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(declared, FeatureType):
|
|
141
|
+
feature_type = declared
|
|
142
|
+
else:
|
|
143
|
+
try:
|
|
144
|
+
feature_type = FeatureType(declared)
|
|
145
|
+
except ValueError as error:
|
|
146
|
+
valid = [member.value for member in FeatureType]
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"feature_types[{name!r}] = {declared!r} is not a valid "
|
|
149
|
+
f"FeatureType; valid values are {valid}"
|
|
150
|
+
) from error
|
|
151
|
+
if feature_type not in _SUPPORTED_FEATURE_TYPES:
|
|
152
|
+
supported = [member.value for member in _SUPPORTED_FEATURE_TYPES]
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"feature_types[{name!r}] = {feature_type.value!r} is not yet "
|
|
155
|
+
f"supported by signapy.discover; supported feature types are "
|
|
156
|
+
f"{supported}"
|
|
157
|
+
)
|
|
158
|
+
return feature_type
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def discover(
|
|
162
|
+
df: pd.DataFrame | pl.DataFrame,
|
|
163
|
+
target: str,
|
|
164
|
+
*,
|
|
165
|
+
positive_class: Hashable,
|
|
166
|
+
feature_types: Mapping[str, FeatureType | str] | None = None,
|
|
167
|
+
) -> DiscoveryReport:
|
|
168
|
+
"""Discover feature/value evidence against a binary target.
|
|
169
|
+
|
|
170
|
+
Computes feature-level evidence for each selected feature — and, for
|
|
171
|
+
categorical and ordinal features, value-level evidence too — and returns
|
|
172
|
+
them as a :class:`~signapy.results.DiscoveryReport`.
|
|
173
|
+
|
|
174
|
+
- **Categorical** features get bias-corrected Cramér's V, a chi-square
|
|
175
|
+
test, and per-category support/target rate/baseline rate/lift, in
|
|
176
|
+
order of first appearance (``FeatureResult.values`` populated).
|
|
177
|
+
- **Continuous** features get a point-biserial correlation and its
|
|
178
|
+
p-value, group counts and means in ``details``,
|
|
179
|
+
and per-bin support/target rate/baseline rate/lift over up to five
|
|
180
|
+
bins, labelled as intervals or isolated repeated values, in ascending
|
|
181
|
+
order
|
|
182
|
+
(``FeatureResult.values`` is ``None`` only if the feature can't be split
|
|
183
|
+
into at least two bins).
|
|
184
|
+
- **Ordinal** features get a Spearman rank correlation and its p-value,
|
|
185
|
+
plus the number of distinct levels in ``details``, and per-level
|
|
186
|
+
support/target rate/baseline rate/lift like categorical — but in the
|
|
187
|
+
feature's *declared* category order, not order of first appearance.
|
|
188
|
+
Ordinal features must carry an explicit order: an ordered pandas
|
|
189
|
+
``Categorical`` (``pd.Categorical(..., ordered=True)``) or a Polars
|
|
190
|
+
``Enum``. SignaPy never infers an order, from the values or
|
|
191
|
+
alphabetically.
|
|
192
|
+
|
|
193
|
+
This is SignaPy's discovery layer for binary targets only. ``df`` is
|
|
194
|
+
never mutated. Polars input gives the same results as the equivalent
|
|
195
|
+
pandas input; a Polars ``Enum`` column maps to an ordered ``Categorical``
|
|
196
|
+
(usable as ORDINAL), and Polars ``NaN`` is treated as missing like null.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
df: The labeled dataset: a pandas or Polars ``DataFrame``. Polars
|
|
200
|
+
input is read without mutation and without requiring PyArrow;
|
|
201
|
+
see :mod:`signapy.discovery._frame` for the dtype mapping.
|
|
202
|
+
target: Name of the binary target column in ``df``.
|
|
203
|
+
positive_class: The target value treated as the positive class.
|
|
204
|
+
feature_types: Optional mapping from column name to
|
|
205
|
+
:class:`~signapy.profiling.FeatureType` (or its string value,
|
|
206
|
+
e.g. ``"categorical"``), selecting exactly which columns to
|
|
207
|
+
analyze and how. When given:
|
|
208
|
+
|
|
209
|
+
- Its keys are the *only* features analyzed; other columns of
|
|
210
|
+
``df`` are ignored.
|
|
211
|
+
- ``target`` must not be one of the keys.
|
|
212
|
+
- Every key must be a column of ``df``.
|
|
213
|
+
- Only ``FeatureType.CATEGORICAL``, ``FeatureType.CONTINUOUS``,
|
|
214
|
+
and ``FeatureType.ORDINAL`` are supported in this release.
|
|
215
|
+
|
|
216
|
+
When omitted (``None``, the default), every non-target column
|
|
217
|
+
of ``df`` is analyzed as categorical — this preserves the
|
|
218
|
+
behavior from before ``feature_types`` existed, including
|
|
219
|
+
raising if a column's dtype isn't a supported categorical dtype.
|
|
220
|
+
Numeric columns are never silently treated as continuous, and
|
|
221
|
+
categorical columns are never silently treated as ordinal:
|
|
222
|
+
declaring them via ``feature_types`` is required, because e.g.
|
|
223
|
+
an integer column might be a measured quantity, an ordinal
|
|
224
|
+
level, or a category identifier, and SignaPy does not guess
|
|
225
|
+
which.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
A :class:`~signapy.results.DiscoveryReport` with one
|
|
229
|
+
:class:`~signapy.results.FeatureResult` per selected feature, in
|
|
230
|
+
the same order as ``df.columns`` (restricted to the selected
|
|
231
|
+
features when ``feature_types`` is given). For machine-readable
|
|
232
|
+
output (scripts, JSON, agents), call
|
|
233
|
+
:meth:`~signapy.results.DiscoveryReport.to_dict` — ``print(report)``
|
|
234
|
+
shows only one summary row per feature.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
TypeError: If ``df`` is neither a pandas nor Polars ``DataFrame``.
|
|
238
|
+
ValueError: If ``target`` is not a column of ``df``; if ``df`` is
|
|
239
|
+
empty; if ``target``'s non-missing values are not exactly two
|
|
240
|
+
distinct classes; if ``positive_class`` is not one of them; if
|
|
241
|
+
``feature_types`` is an empty mapping, includes ``target``, or
|
|
242
|
+
names a column not in ``df``; if a declared feature type is
|
|
243
|
+
invalid or not yet supported (see :func:`_resolve_feature_type`);
|
|
244
|
+
if a categorical column's dtype is unsupported (see
|
|
245
|
+
:func:`_is_supported_categorical_dtype`); if an ordinal column
|
|
246
|
+
isn't an ordered ``Categorical`` (see :func:`_check_ordinal_dtype`);
|
|
247
|
+
or if a feature's own data is invalid for its declared type once
|
|
248
|
+
its missing values are dropped (see
|
|
249
|
+
:func:`signapy.discovery.feature.analyze_categorical_feature`,
|
|
250
|
+
:func:`signapy.discovery.feature.analyze_continuous_feature`,
|
|
251
|
+
and :func:`signapy.discovery.feature.analyze_ordinal_feature`).
|
|
252
|
+
|
|
253
|
+
Missing-data policy (see ``docs/architecture.md`` for the full
|
|
254
|
+
rationale), the same for every feature type:
|
|
255
|
+
|
|
256
|
+
1. Rows with a missing ``target`` are excluded from all analysis.
|
|
257
|
+
2. For each feature, rows with a missing value for *that* feature are
|
|
258
|
+
additionally excluded from *that feature's* analysis only; other
|
|
259
|
+
features are unaffected.
|
|
260
|
+
3. ``FeatureResult.n`` is the number of rows with both a non-missing
|
|
261
|
+
target and a non-missing value for that feature.
|
|
262
|
+
4. ``FeatureResult.missing_rate`` is the fraction of target-valid rows
|
|
263
|
+
where that feature is missing.
|
|
264
|
+
5. For categorical and ordinal features, value-level baseline rate and
|
|
265
|
+
lift are computed from the same feature-valid rows used for that
|
|
266
|
+
feature's feature-level statistic.
|
|
267
|
+
6. Infinite values are invalid, not missing — a continuous feature
|
|
268
|
+
containing ``inf``/``-inf`` raises rather than having those rows
|
|
269
|
+
silently dropped.
|
|
270
|
+
"""
|
|
271
|
+
if is_polars_dataframe(df):
|
|
272
|
+
columns = list(df.columns)
|
|
273
|
+
if target in columns:
|
|
274
|
+
wanted = columns if feature_types is None else {target, *feature_types}
|
|
275
|
+
df = polars_to_pandas(df, wanted)
|
|
276
|
+
elif not isinstance(df, pd.DataFrame):
|
|
277
|
+
raise TypeError(
|
|
278
|
+
f"df must be a pandas or Polars DataFrame, got {type(df).__name__}"
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
columns = list(df.columns)
|
|
282
|
+
|
|
283
|
+
if target not in columns:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"target column {target!r} not found in DataFrame columns: {columns}"
|
|
286
|
+
)
|
|
287
|
+
if df.empty:
|
|
288
|
+
raise ValueError("df must not be empty")
|
|
289
|
+
|
|
290
|
+
target_valid_mask = df[target].notna()
|
|
291
|
+
target_valid_df = df.loc[target_valid_mask]
|
|
292
|
+
|
|
293
|
+
unique_targets = pd.unique(target_valid_df[target])
|
|
294
|
+
if len(unique_targets) != 2:
|
|
295
|
+
labels = sorted(str(value) for value in unique_targets)
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"target column {target!r} must be binary, found "
|
|
298
|
+
f"{len(unique_targets)} distinct non-missing classes: {labels}"
|
|
299
|
+
)
|
|
300
|
+
if positive_class not in unique_targets:
|
|
301
|
+
labels = sorted(str(value) for value in unique_targets)
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"positive_class {positive_class!r} is not one of the observed "
|
|
304
|
+
f"target classes {labels}; check for a typo or the wrong value"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if feature_types is None:
|
|
308
|
+
# Backward-compatible default: every non-target column, analyzed as
|
|
309
|
+
# categorical. Equivalent to declaring every column CATEGORICAL
|
|
310
|
+
# explicitly, so it goes through the same resolved_types/dispatch
|
|
311
|
+
# path below rather than a separate code path to keep in sync.
|
|
312
|
+
resolved_types: dict[str, FeatureType] = {
|
|
313
|
+
column: FeatureType.CATEGORICAL for column in df.columns if column != target
|
|
314
|
+
}
|
|
315
|
+
else:
|
|
316
|
+
if len(feature_types) == 0:
|
|
317
|
+
raise ValueError("feature_types must not be empty")
|
|
318
|
+
if target in feature_types:
|
|
319
|
+
raise ValueError(
|
|
320
|
+
f"feature_types must not include the target column {target!r}"
|
|
321
|
+
)
|
|
322
|
+
for name in feature_types:
|
|
323
|
+
if name not in columns:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"feature_types names {name!r}, which is not a column "
|
|
326
|
+
f"of df: {columns}"
|
|
327
|
+
)
|
|
328
|
+
resolved_types = {
|
|
329
|
+
name: _resolve_feature_type(name, declared)
|
|
330
|
+
for name, declared in feature_types.items()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
feature_columns = [column for column in df.columns if column in resolved_types]
|
|
334
|
+
results = []
|
|
335
|
+
for name in feature_columns:
|
|
336
|
+
feature_type = resolved_types[name]
|
|
337
|
+
series = target_valid_df[name]
|
|
338
|
+
|
|
339
|
+
analyzer = _DISPATCH.get((feature_type, TargetType.BINARY))
|
|
340
|
+
if analyzer is None:
|
|
341
|
+
# Currently unreachable: _resolve_feature_type only allows
|
|
342
|
+
# values in _SUPPORTED_FEATURE_TYPES, which are exactly
|
|
343
|
+
# _DISPATCH's keys for TargetType.BINARY (the only target type
|
|
344
|
+
# this layer supports). Kept as an explicit, named failure
|
|
345
|
+
# rather than a silent KeyError so it stays correct if either
|
|
346
|
+
# set changes independently in the future.
|
|
347
|
+
supported = sorted(f"{ft.value}+{tt.value}" for ft, tt in _DISPATCH)
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"feature {name!r} declared as {feature_type.value!r} "
|
|
350
|
+
f"against a {TargetType.BINARY.value!r} target has no "
|
|
351
|
+
f"discovery method; supported combinations are {supported}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if feature_type is FeatureType.CATEGORICAL and not (
|
|
355
|
+
_is_supported_categorical_dtype(series)
|
|
356
|
+
):
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"feature {name!r} has unsupported dtype {series.dtype}; "
|
|
359
|
+
"signapy.discover only supports categorical columns "
|
|
360
|
+
'(object, pandas "string", or pandas "category" dtype) in '
|
|
361
|
+
"this release. Continuous and ordinal features must be "
|
|
362
|
+
"declared via feature_types; boolean and integer-coded-"
|
|
363
|
+
"category features are not yet supported — see "
|
|
364
|
+
"docs/architecture.md."
|
|
365
|
+
)
|
|
366
|
+
if feature_type is FeatureType.ORDINAL:
|
|
367
|
+
_check_ordinal_dtype(name, series)
|
|
368
|
+
|
|
369
|
+
results.append(
|
|
370
|
+
analyzer(
|
|
371
|
+
name,
|
|
372
|
+
series,
|
|
373
|
+
target_valid_df[target],
|
|
374
|
+
positive_class=positive_class,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return DiscoveryReport(target=target, features=tuple(results))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Dataframe adapter: lets ``discover`` accept a Polars ``DataFrame``.
|
|
2
|
+
|
|
3
|
+
SignaPy's analysis layer is pandas-based. Rather than converting through
|
|
4
|
+
``DataFrame.to_pandas()`` (which requires PyArrow), selected Polars columns
|
|
5
|
+
are rebuilt as pandas Series via ``to_numpy()`` / ``to_list()``, which need
|
|
6
|
+
neither PyArrow nor any other optional dependency. The Polars frame is only
|
|
7
|
+
read, never mutated. Polars itself is never imported unless the caller
|
|
8
|
+
already passed a Polars object.
|
|
9
|
+
|
|
10
|
+
Dtype mapping (Polars -> pandas), chosen so that each column lands on the
|
|
11
|
+
same pandas dtype a user would get from the equivalent pandas workflow, and
|
|
12
|
+
so SignaPy's existing dtype validation behaves identically:
|
|
13
|
+
|
|
14
|
+
- integer: NumPy numeric array when complete, or the matching pandas nullable
|
|
15
|
+
integer dtype when nulls are present, preserving integer values exactly.
|
|
16
|
+
The 128-bit types, which pandas does not provide, use exact Python integers.
|
|
17
|
+
- float: NumPy numeric array. Nulls and NaNs become ``NaN``.
|
|
18
|
+
- ``String``: ``object`` dtype (``None`` for nulls).
|
|
19
|
+
- ``Categorical``: unordered pandas ``category``.
|
|
20
|
+
- ``Enum``: *ordered* pandas ``category`` in the Enum's declared order, so
|
|
21
|
+
an Enum column can be declared ORDINAL directly.
|
|
22
|
+
- ``Boolean``: ``bool``, or nullable ``boolean`` if it contains nulls
|
|
23
|
+
(never ``object``, so it stays unsupported as categorical/continuous
|
|
24
|
+
exactly like a pandas boolean column).
|
|
25
|
+
- ``Date`` / ``Datetime`` / ``Duration``: ``datetime64`` / ``timedelta64``.
|
|
26
|
+
- anything else: ``object`` dtype.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from collections.abc import Container
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_polars_dataframe(obj: object) -> bool:
|
|
38
|
+
"""Whether ``obj`` is a Polars ``DataFrame``, without importing Polars."""
|
|
39
|
+
cls = type(obj)
|
|
40
|
+
return cls.__name__ == "DataFrame" and cls.__module__.split(".")[0] == "polars"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _convert_series(series: Any) -> pd.Series:
|
|
44
|
+
import polars as pl
|
|
45
|
+
|
|
46
|
+
dtype = series.dtype
|
|
47
|
+
if isinstance(dtype, pl.Enum):
|
|
48
|
+
values: Any = pd.Categorical(
|
|
49
|
+
series.to_list(), categories=dtype.categories.to_list(), ordered=True
|
|
50
|
+
)
|
|
51
|
+
elif isinstance(dtype, pl.Categorical):
|
|
52
|
+
values = pd.Categorical(series.to_list())
|
|
53
|
+
elif dtype == pl.Boolean:
|
|
54
|
+
values = (
|
|
55
|
+
pd.array(series.to_list(), dtype="boolean")
|
|
56
|
+
if series.null_count()
|
|
57
|
+
else series.to_numpy()
|
|
58
|
+
)
|
|
59
|
+
elif dtype.is_integer() and "128" in str(dtype):
|
|
60
|
+
values = pd.Series(series.to_list(), dtype=object)
|
|
61
|
+
elif dtype.is_integer() and series.null_count():
|
|
62
|
+
values = pd.array(series.to_list(), dtype=str(dtype))
|
|
63
|
+
elif (dtype.is_numeric() and dtype != pl.Decimal) or (
|
|
64
|
+
dtype.is_temporal() and dtype != pl.Time
|
|
65
|
+
):
|
|
66
|
+
values = series.to_numpy()
|
|
67
|
+
else:
|
|
68
|
+
values = pd.Series(series.to_list(), dtype=object)
|
|
69
|
+
return pd.Series(values, name=series.name)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def polars_to_pandas(df: Any, columns: Container[Any]) -> pd.DataFrame:
|
|
73
|
+
"""Convert the ``columns`` of Polars ``df`` to a pandas DataFrame.
|
|
74
|
+
|
|
75
|
+
Column order follows ``df``. Columns not in ``columns`` are skipped
|
|
76
|
+
entirely (never converted), so unused columns cost nothing.
|
|
77
|
+
"""
|
|
78
|
+
return pd.concat(
|
|
79
|
+
[_convert_series(df[name]) for name in df.columns if name in columns],
|
|
80
|
+
axis=1,
|
|
81
|
+
)
|