AutoCarver 7.3.2__tar.gz → 7.3.3__tar.gz
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.
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/multiclass_carver.py +3 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/utils/base_carver.py +22 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/__init__.py +10 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/features.py +96 -33
- autocarver-7.3.3/AutoCarver/features/llm_qualifier.py +185 -0
- autocarver-7.3.3/AutoCarver/mcp/__init__.py +63 -0
- autocarver-7.3.3/AutoCarver/mcp/__main__.py +6 -0
- autocarver-7.3.3/AutoCarver/mcp/inspection.py +168 -0
- autocarver-7.3.3/AutoCarver/mcp/server.py +111 -0
- autocarver-7.3.3/AutoCarver/mcp/session.py +222 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/PKG-INFO +13 -1
- {autocarver-7.3.2 → autocarver-7.3.3}/README.md +9 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/pyproject.toml +8 -1
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/binary_carver.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/continuous_carver.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/utils/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/utils/pretty_print.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/binary/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/binary/binary_combination_evaluators.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/binary/binary_target_rates.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/continuous/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/continuous/continuous_combination_evaluators.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/continuous/continuous_target_rates.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/combination_evaluator.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/combinations.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/target_rate.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/testing.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/config.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/categorical_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/nested_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/ordinal_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/qualitative_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/quantitatives/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/quantitatives/continuous_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/quantitatives/quantitative_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/base_discretizer.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/frequency_ci.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/multiprocessing.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/type_discretizers.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/categorical_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/nested_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/ordinal_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/qualitative_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/datetime_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/numerical_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/quantitative_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/base_feature.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/grouped_list.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/serialization.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/classification_selector.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/base_filters.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/qualitative_filters.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/quantitative_filters.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/_vectorized.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/base_measures.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/qualitative_measures.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/quantitative_measures.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/regression_selector.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/utils/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/utils/base_selector.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/utils/pretty_print.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/__init__.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/attributes.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/dependencies.py +0 -0
- {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/extend_docstring.py +0 -0
|
@@ -89,6 +89,9 @@ class MulticlassCarver(BinaryCarver):
|
|
|
89
89
|
X_dev: pd.DataFrame | None = None,
|
|
90
90
|
y_dev: pd.Series | None = None,
|
|
91
91
|
) -> Self:
|
|
92
|
+
# dropping the target column if it leaked into the features (before versioning)
|
|
93
|
+
self._drop_target_from_features(X, y)
|
|
94
|
+
|
|
92
95
|
# initiating samples
|
|
93
96
|
samples = Samples(train=Sample(X, y), dev=Sample(X_dev, y_dev))
|
|
94
97
|
|
|
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field, replace
|
|
|
8
8
|
from functools import partial
|
|
9
9
|
from multiprocessing import Pool
|
|
10
10
|
from typing import Self
|
|
11
|
+
from warnings import warn
|
|
11
12
|
|
|
12
13
|
import pandas as pd
|
|
13
14
|
|
|
@@ -293,6 +294,24 @@ class BaseCarver(BaseDiscretizer, ABC):
|
|
|
293
294
|
|
|
294
295
|
return samples
|
|
295
296
|
|
|
297
|
+
def _drop_target_from_features(self, X: pd.DataFrame, y: pd.Series | None) -> None:
|
|
298
|
+
"""Drops the target column from ``self.features`` if it leaked in.
|
|
299
|
+
|
|
300
|
+
``Features.from_dataframe`` maps every column of the input, target included; the
|
|
301
|
+
target reaches the carver as the named ``y`` Series, so it is removed here rather
|
|
302
|
+
than at feature-construction time.
|
|
303
|
+
|
|
304
|
+
The guard fires only when ``y`` is genuinely a column of ``X`` (same name *and*
|
|
305
|
+
values): pandas propagates column names through arithmetic, so a derived target
|
|
306
|
+
like ``X[col] * 0.5 + noise`` can share a feature's name without being it.
|
|
307
|
+
"""
|
|
308
|
+
if y is None or y.name is None:
|
|
309
|
+
return
|
|
310
|
+
name = str(y.name)
|
|
311
|
+
if name in self.features and name in X.columns and y.equals(X[name]):
|
|
312
|
+
warn(f"[{self.__name__}] dropping target column {name!r} from features", UserWarning, stacklevel=2)
|
|
313
|
+
self.features.remove(name)
|
|
314
|
+
|
|
296
315
|
def fit( # type: ignore
|
|
297
316
|
self,
|
|
298
317
|
X: pd.DataFrame,
|
|
@@ -332,6 +351,9 @@ class BaseCarver(BaseDiscretizer, ABC):
|
|
|
332
351
|
f"[{self.__name__}] features are already fitted or previous fit failed. Please reset your features."
|
|
333
352
|
)
|
|
334
353
|
|
|
354
|
+
# dropping the target column if it leaked into the features
|
|
355
|
+
self._drop_target_from_features(X, y)
|
|
356
|
+
|
|
335
357
|
# setting is_fitted
|
|
336
358
|
self.features.is_fitted = True
|
|
337
359
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"""Loads Features tools."""
|
|
2
2
|
|
|
3
3
|
from AutoCarver.features.features import Features, FeaturesConfig, get_names, get_versions
|
|
4
|
+
from AutoCarver.features.llm_qualifier import (
|
|
5
|
+
build_qualification_prompt,
|
|
6
|
+
parse_qualification_response,
|
|
7
|
+
qualify_with_llm,
|
|
8
|
+
specs_to_features_kwargs,
|
|
9
|
+
)
|
|
4
10
|
from AutoCarver.features.qualitatives import (
|
|
5
11
|
CategoricalFeature,
|
|
6
12
|
NestedFeature,
|
|
@@ -41,4 +47,8 @@ __all__ = [
|
|
|
41
47
|
"NumericalFeature",
|
|
42
48
|
"DatetimeFeature",
|
|
43
49
|
"QualitativeFeature",
|
|
50
|
+
"build_qualification_prompt",
|
|
51
|
+
"parse_qualification_response",
|
|
52
|
+
"qualify_with_llm",
|
|
53
|
+
"specs_to_features_kwargs",
|
|
44
54
|
]
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from collections.abc import Iterable
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from typing import TypeVar, overload
|
|
6
|
+
from warnings import warn
|
|
6
7
|
|
|
7
8
|
import numpy as np
|
|
8
9
|
import pandas as pd
|
|
@@ -48,39 +49,49 @@ class FeaturesConfig:
|
|
|
48
49
|
dropna: bool = False
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
52
|
+
def infer_feature_kind(dtype) -> str | None:
|
|
53
|
+
"""Maps a pandas dtype to a feature kind, or ``None`` when unsupported.
|
|
54
|
+
|
|
55
|
+
Single source of truth for the dtype-based mapping used by both
|
|
56
|
+
:meth:`Features.from_dataframe` and the MCP inspection layer. Returns one of
|
|
57
|
+
``"ordinal"`` / ``"datetime"`` / ``"numerical"`` / ``"categorical"``.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(dtype, pd.CategoricalDtype) and dtype.ordered:
|
|
60
|
+
return "ordinal"
|
|
61
|
+
if pd.api.types.is_datetime64_any_dtype(dtype):
|
|
62
|
+
return "datetime"
|
|
63
|
+
if pd.api.types.is_bool_dtype(dtype):
|
|
64
|
+
return "categorical"
|
|
65
|
+
if pd.api.types.is_numeric_dtype(dtype):
|
|
66
|
+
return "numerical"
|
|
67
|
+
if isinstance(dtype, pd.CategoricalDtype) or dtype == "object" or pd.api.types.is_string_dtype(dtype):
|
|
68
|
+
return "categorical"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_datetime_references(X: pd.DataFrame, datetime_cols: list[str]) -> dict[str, str]:
|
|
73
|
+
"""Picks a ``reference_date`` for each auto-detected datetime column.
|
|
74
|
+
|
|
75
|
+
Every datetime column is measured (row-wise) against the most recent datetime column —
|
|
76
|
+
the ``anchor``, chosen as the column with the latest observation. The anchor can't
|
|
77
|
+
reference itself, so it falls back to a fixed literal (its own earliest date); the same
|
|
78
|
+
fallback applies when there is a single datetime column.
|
|
79
|
+
"""
|
|
80
|
+
# most recent observation per column (NaT for all-NaT columns, which can't be anchors)
|
|
81
|
+
representatives = {col: X[col].max() for col in datetime_cols}
|
|
82
|
+
anchor_candidates = [col for col in datetime_cols if pd.notna(representatives[col])]
|
|
83
|
+
|
|
84
|
+
anchor = max(anchor_candidates, key=lambda col: representatives[col]) if anchor_candidates else None
|
|
85
|
+
|
|
86
|
+
references: dict[str, str] = {}
|
|
87
|
+
for col in datetime_cols:
|
|
88
|
+
if anchor is not None and col != anchor:
|
|
89
|
+
references[col] = anchor # row-wise reference against the most recent column
|
|
90
|
+
else:
|
|
91
|
+
# anchor (or single/all-NaT column): fixed literal = its earliest date
|
|
92
|
+
earliest = X[col].min()
|
|
93
|
+
references[col] = str(earliest.date()) if pd.notna(earliest) else str(earliest)
|
|
94
|
+
return references
|
|
84
95
|
|
|
85
96
|
|
|
86
97
|
def _check_names_only(names: list[str] | None, kind: str) -> None:
|
|
@@ -230,6 +241,58 @@ class Features:
|
|
|
230
241
|
instance._build(_dedupe_by_version(feature_list), config)
|
|
231
242
|
return instance
|
|
232
243
|
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_dataframe(cls, X: pd.DataFrame, config: FeaturesConfig | None = None) -> "Features":
|
|
246
|
+
"""Build a :class:`Features` by mapping each column of ``X`` to a feature type.
|
|
247
|
+
|
|
248
|
+
Mapping rules (deterministic, from each column's dtype):
|
|
249
|
+
|
|
250
|
+
- ordered ``category`` → :class:`OrdinalFeature` (order lifted from ``.cat.categories``)
|
|
251
|
+
- numeric (non-bool) → :class:`NumericalFeature`
|
|
252
|
+
- ``datetime64`` → :class:`DatetimeFeature` (``reference_date`` resolved against the
|
|
253
|
+
most recent datetime column, see :func:`_resolve_datetime_references`)
|
|
254
|
+
- ``bool`` / ``object`` / ``string`` / unordered ``category`` → :class:`CategoricalFeature`
|
|
255
|
+
- any other dtype → skipped with a warning
|
|
256
|
+
|
|
257
|
+
Nested features can't be inferred from a single column's dtype and are not produced;
|
|
258
|
+
declare them explicitly or use :func:`qualify_with_llm`. The target column, if present
|
|
259
|
+
in ``X``, is mapped like any other column and dropped later by the carver (see
|
|
260
|
+
``BaseCarver._drop_target_from_features``).
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
X : pd.DataFrame
|
|
265
|
+
DataFrame whose columns are mapped to typed features.
|
|
266
|
+
|
|
267
|
+
config : FeaturesConfig, optional
|
|
268
|
+
Collection-level config propagated to each feature, by default ``None``.
|
|
269
|
+
"""
|
|
270
|
+
datetime_cols = [col for col in X.columns if pd.api.types.is_datetime64_any_dtype(X[col])]
|
|
271
|
+
references = _resolve_datetime_references(X, datetime_cols)
|
|
272
|
+
|
|
273
|
+
features: list[BaseFeature] = []
|
|
274
|
+
for col, dtype in X.dtypes.items():
|
|
275
|
+
name = str(col)
|
|
276
|
+
kind = infer_feature_kind(dtype)
|
|
277
|
+
if kind == "ordinal":
|
|
278
|
+
features.append(OrdinalFeature(name, values=[str(c) for c in dtype.categories]))
|
|
279
|
+
elif kind == "datetime":
|
|
280
|
+
features.append(DatetimeFeature(name, reference_date=references[col]))
|
|
281
|
+
elif kind == "numerical":
|
|
282
|
+
features.append(NumericalFeature(name))
|
|
283
|
+
elif kind == "categorical":
|
|
284
|
+
features.append(CategoricalFeature(name))
|
|
285
|
+
else:
|
|
286
|
+
warn(
|
|
287
|
+
f"[{cls.__name__}] Omitted column {name!r}, unsupported data type {dtype}.",
|
|
288
|
+
UserWarning,
|
|
289
|
+
stacklevel=2,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
instance = cls.__new__(cls)
|
|
293
|
+
instance._build(features, config)
|
|
294
|
+
return instance
|
|
295
|
+
|
|
233
296
|
def _build(self, features: list[BaseFeature], config: FeaturesConfig | None) -> None:
|
|
234
297
|
"""Shared construction body: apply config and group by type."""
|
|
235
298
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Provider-agnostic helpers to qualify a DataFrame's columns into :class:`Features` via an LLM.
|
|
2
|
+
|
|
3
|
+
These helpers never import any provider SDK. The caller supplies ``llm_fn``, a callable taking
|
|
4
|
+
the prompt string and returning the model's raw text answer, so any backend can be plugged in.
|
|
5
|
+
|
|
6
|
+
Anthropic example (latest model id ``claude-opus-4-8``)::
|
|
7
|
+
|
|
8
|
+
from anthropic import Anthropic
|
|
9
|
+
|
|
10
|
+
client = Anthropic()
|
|
11
|
+
|
|
12
|
+
def llm_fn(prompt: str) -> str:
|
|
13
|
+
msg = client.messages.create(
|
|
14
|
+
model="claude-opus-4-8",
|
|
15
|
+
max_tokens=2000,
|
|
16
|
+
messages=[{"role": "user", "content": prompt}],
|
|
17
|
+
)
|
|
18
|
+
return msg.content[0].text
|
|
19
|
+
|
|
20
|
+
features = qualify_with_llm(X, llm_fn)
|
|
21
|
+
|
|
22
|
+
OpenAI example::
|
|
23
|
+
|
|
24
|
+
from openai import OpenAI
|
|
25
|
+
|
|
26
|
+
client = OpenAI()
|
|
27
|
+
|
|
28
|
+
def llm_fn(prompt: str) -> str:
|
|
29
|
+
resp = client.chat.completions.create(
|
|
30
|
+
model="gpt-4o",
|
|
31
|
+
messages=[{"role": "user", "content": prompt}],
|
|
32
|
+
)
|
|
33
|
+
return resp.choices[0].message.content
|
|
34
|
+
|
|
35
|
+
features = qualify_with_llm(X, llm_fn)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
from AutoCarver.features.features import Features, FeaturesConfig
|
|
44
|
+
|
|
45
|
+
# the JSON contract the model must follow, embedded in the prompt and used to parse the answer
|
|
46
|
+
_SCHEMA_INSTRUCTIONS = """\
|
|
47
|
+
Return ONLY a JSON object (no prose, no markdown fences) mapping every column name to an object
|
|
48
|
+
describing its feature type. Each value must have a "type" field, one of:
|
|
49
|
+
|
|
50
|
+
- "numerical": a quantitative column.
|
|
51
|
+
- "categorical": an unordered qualitative column.
|
|
52
|
+
- "ordinal": an ordered qualitative column. Add "values": the full list of categories from
|
|
53
|
+
smallest/lowest to largest/highest (strings).
|
|
54
|
+
- "datetime": a date/time column. Add "reference": either the name of another datetime column
|
|
55
|
+
to measure elapsed time against, or a fixed date literal like "2020-01-01".
|
|
56
|
+
- "nested": a fine-grained qualitative column that rolls up into coarser columns. Add "parents":
|
|
57
|
+
the list of coarser-ward parent column names, from nearest to farthest.
|
|
58
|
+
- "ignore": a column that should not become a feature (ids, free text, leakage, etc.).
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
{"age": {"type": "numerical"},
|
|
62
|
+
"city": {"type": "categorical"},
|
|
63
|
+
"grade": {"type": "ordinal", "values": ["low", "medium", "high"]},
|
|
64
|
+
"signed_at": {"type": "datetime", "reference": "observed_at"},
|
|
65
|
+
"product": {"type": "nested", "parents": ["category", "division"]},
|
|
66
|
+
"user_id": {"type": "ignore"}}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_qualification_prompt(X: pd.DataFrame, *, sample_size: int = 20) -> str:
|
|
71
|
+
"""Builds the qualification prompt describing every column of ``X`` for the LLM.
|
|
72
|
+
|
|
73
|
+
Each column is summarised by its name, dtype, number of unique values and a small sample of
|
|
74
|
+
values so the model can infer its feature type and any ordering / hierarchy.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
X : pd.DataFrame
|
|
79
|
+
DataFrame whose columns are described.
|
|
80
|
+
sample_size : int, optional
|
|
81
|
+
Maximum number of sample values shown per column, by default ``20``.
|
|
82
|
+
"""
|
|
83
|
+
lines = ["You are qualifying the columns of a tabular dataset for the AutoCarver library.", ""]
|
|
84
|
+
for col in X.columns:
|
|
85
|
+
series = X[col]
|
|
86
|
+
sample = series.dropna().unique()[:sample_size]
|
|
87
|
+
sample_repr = ", ".join(map(str, sample))
|
|
88
|
+
lines.append(f"- {col!r} (dtype={series.dtype}, n_unique={series.nunique()}): {sample_repr}")
|
|
89
|
+
lines += ["", _SCHEMA_INSTRUCTIONS]
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require(spec: dict, key: str, col: str, kind: str):
|
|
94
|
+
"""Returns ``spec[key]`` or raises a clear error naming the column and its kind."""
|
|
95
|
+
if key not in spec:
|
|
96
|
+
raise ValueError(f"[qualify] {kind} column {col!r} is missing its {key!r}.")
|
|
97
|
+
return spec[key]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def specs_to_features_kwargs(mapping: dict) -> dict:
|
|
101
|
+
"""Routes a ``{column: {"type": ..., ...}}`` mapping into :class:`Features` kwargs.
|
|
102
|
+
|
|
103
|
+
Returns a dict with the ``categoricals``, ``numericals``, ``ordinals``, ``datetimes`` and
|
|
104
|
+
``nested`` keys expected by ``Features(**kwargs)``. ``ignore`` columns are dropped. Shared
|
|
105
|
+
by the LLM qualifier and the MCP session so the type-routing has a single source of truth.
|
|
106
|
+
|
|
107
|
+
Raises
|
|
108
|
+
------
|
|
109
|
+
ValueError
|
|
110
|
+
If a column declares an unknown / incomplete type.
|
|
111
|
+
"""
|
|
112
|
+
categoricals: list[str] = []
|
|
113
|
+
numericals: list[str] = []
|
|
114
|
+
ordinals: dict[str, list[str]] = {}
|
|
115
|
+
datetimes: list[tuple[str, str]] = []
|
|
116
|
+
nested: dict[str, list[str]] = {}
|
|
117
|
+
|
|
118
|
+
for col, spec in mapping.items():
|
|
119
|
+
kind = spec.get("type")
|
|
120
|
+
if kind == "numerical":
|
|
121
|
+
numericals.append(col)
|
|
122
|
+
elif kind == "categorical":
|
|
123
|
+
categoricals.append(col)
|
|
124
|
+
elif kind == "ordinal":
|
|
125
|
+
ordinals[col] = [str(value) for value in _require(spec, "values", col, kind)]
|
|
126
|
+
elif kind == "datetime":
|
|
127
|
+
datetimes.append((col, str(_require(spec, "reference", col, kind))))
|
|
128
|
+
elif kind == "nested":
|
|
129
|
+
nested[col] = [str(parent) for parent in _require(spec, "parents", col, kind)]
|
|
130
|
+
elif kind != "ignore":
|
|
131
|
+
raise ValueError(f"[qualify] column {col!r} has unknown type {kind!r}.")
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"categoricals": categoricals,
|
|
135
|
+
"numericals": numericals,
|
|
136
|
+
"ordinals": ordinals,
|
|
137
|
+
"datetimes": datetimes,
|
|
138
|
+
"nested": nested,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_qualification_response(response: str) -> dict:
|
|
143
|
+
"""Parses the LLM's JSON answer into keyword arguments for :class:`Features`.
|
|
144
|
+
|
|
145
|
+
Extracts the JSON object from ``response`` and routes it through
|
|
146
|
+
:func:`specs_to_features_kwargs`.
|
|
147
|
+
|
|
148
|
+
Raises
|
|
149
|
+
------
|
|
150
|
+
ValueError
|
|
151
|
+
If no JSON object can be parsed, or a column declares an unknown / incomplete type.
|
|
152
|
+
"""
|
|
153
|
+
start, end = response.find("{"), response.rfind("}")
|
|
154
|
+
if start == -1 or end == -1 or end < start:
|
|
155
|
+
raise ValueError(f"[qualify] No JSON object found in LLM response: {response!r}")
|
|
156
|
+
try:
|
|
157
|
+
mapping = json.loads(response[start : end + 1])
|
|
158
|
+
except json.JSONDecodeError as error:
|
|
159
|
+
raise ValueError(f"[qualify] Could not parse JSON from LLM response: {error}") from error
|
|
160
|
+
return specs_to_features_kwargs(mapping)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def qualify_with_llm(
|
|
164
|
+
X: pd.DataFrame,
|
|
165
|
+
llm_fn: Callable[[str], str],
|
|
166
|
+
*,
|
|
167
|
+
sample_size: int = 20,
|
|
168
|
+
config: FeaturesConfig | None = None,
|
|
169
|
+
) -> Features:
|
|
170
|
+
"""Builds a :class:`Features` by asking ``llm_fn`` to qualify every column of ``X``.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
X : pd.DataFrame
|
|
175
|
+
DataFrame whose columns are qualified.
|
|
176
|
+
llm_fn : Callable[[str], str]
|
|
177
|
+
Callable taking the prompt and returning the model's raw text answer.
|
|
178
|
+
sample_size : int, optional
|
|
179
|
+
Maximum number of sample values shown per column, by default ``20``.
|
|
180
|
+
config : FeaturesConfig, optional
|
|
181
|
+
Collection-level config propagated to each feature, by default ``None``.
|
|
182
|
+
"""
|
|
183
|
+
prompt = build_qualification_prompt(X, sample_size=sample_size)
|
|
184
|
+
kwargs = parse_qualification_response(llm_fn(prompt))
|
|
185
|
+
return Features(config=config, **kwargs)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""AutoCarver MCP: a local server exposing the qualify-and-carve workflow as LLM tools.
|
|
2
|
+
|
|
3
|
+
==============================================================================
|
|
4
|
+
MCP setup (read this first if you are an AI assistant / LLM agent)
|
|
5
|
+
==============================================================================
|
|
6
|
+
Do **not** drive this package by importing :class:`CarverSession` and calling its
|
|
7
|
+
methods by hand, and do **not** reimplement the qualify-and-carve steps yourself.
|
|
8
|
+
The supported way to use it is the running MCP server, reached through your client's
|
|
9
|
+
tool calls. To get there:
|
|
10
|
+
|
|
11
|
+
1. Install the optional extra (the server needs ``fastmcp``, which is *not* a core
|
|
12
|
+
dependency)::
|
|
13
|
+
|
|
14
|
+
pip install "autocarver[mcp]" # or: uv add "autocarver[mcp]"
|
|
15
|
+
|
|
16
|
+
2. Register the server with your MCP client so it launches over stdio.
|
|
17
|
+
|
|
18
|
+
VS Code / GitHub Copilot — add ``.vscode/mcp.json`` to the workspace::
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
"servers": {
|
|
22
|
+
"autocarver": {
|
|
23
|
+
"command": "uv",
|
|
24
|
+
"args": ["run", "python", "-m", "AutoCarver.mcp"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Claude Desktop / Cursor — same shape in ``claude_desktop_config.json``, but the
|
|
30
|
+
top-level key is ``mcpServers`` instead of ``servers``.
|
|
31
|
+
|
|
32
|
+
3. Reload the client and use the exposed tools (``load_dataset`` -> ``list_columns`` /
|
|
33
|
+
``feature_distribution`` / ``validate_nesting`` -> ``suggest_features`` /
|
|
34
|
+
``set_feature`` -> ``run_carver`` -> ``save_carver``). That is the qualify+carve
|
|
35
|
+
workflow — let the tools run it, rather than calling Python directly.
|
|
36
|
+
|
|
37
|
+
Two things to keep in mind:
|
|
38
|
+
|
|
39
|
+
* **Local data.** The server runs entirely on the user's machine; it reads files from their
|
|
40
|
+
filesystem and never sends the dataset to AutoCarver or any external service. The only data
|
|
41
|
+
leaving the machine is whatever you (the assistant) relay to your own LLM provider.
|
|
42
|
+
* **Human review.** Feature qualification is LLM-dependent and can be wrong; tell the user to
|
|
43
|
+
review and confirm the feature definitions before any production use.
|
|
44
|
+
|
|
45
|
+
The :class:`CarverSession` / :mod:`inspection` Python layers below exist so the tools
|
|
46
|
+
are importable and unit-tested without ``fastmcp``; they are an implementation detail,
|
|
47
|
+
not the entry point. See ``docs/source/mcp.rst`` for the full guide.
|
|
48
|
+
==============================================================================
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from AutoCarver.mcp.session import CarverSession
|
|
52
|
+
|
|
53
|
+
__all__ = ["CarverSession", "create_server", "main"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __getattr__(name: str):
|
|
57
|
+
# lazily import the FastMCP-dependent server so the package (and its testable
|
|
58
|
+
# inspection/session layers) import fine even when fastmcp is unavailable.
|
|
59
|
+
if name in ("create_server", "main"):
|
|
60
|
+
from AutoCarver.mcp import server
|
|
61
|
+
|
|
62
|
+
return getattr(server, name)
|
|
63
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Read-only data-inspection helpers backing the AutoCarver MCP tools.
|
|
2
|
+
|
|
3
|
+
Pure functions over a ``DataFrame`` (no LLM, no MCP, no session state) so they can be unit
|
|
4
|
+
tested directly and reused by data scientists. They return plain JSON-serialisable structures
|
|
5
|
+
(dicts / lists) and never dump raw columns wholesale — only summaries — so they stay safe to
|
|
6
|
+
hand to a model with a limited context window.
|
|
7
|
+
|
|
8
|
+
Note for AI assistants: these helpers back the MCP tools but are not the entry point. To use
|
|
9
|
+
AutoCarver from an MCP client, install ``autocarver[mcp]`` and run the server — see the setup
|
|
10
|
+
guide in :mod:`AutoCarver.mcp` (``AutoCarver/mcp/__init__.py``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from AutoCarver.discretizers.qualitatives.categorical_discretizer import (
|
|
16
|
+
series_target_rate,
|
|
17
|
+
series_value_counts,
|
|
18
|
+
)
|
|
19
|
+
from AutoCarver.discretizers.utils.frequency_ci import is_significantly_below
|
|
20
|
+
from AutoCarver.features.features import infer_feature_kind
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def profile_dataframe(X: pd.DataFrame) -> list[dict]:
|
|
24
|
+
"""One summary row per column: dtype, cardinality, missingness, suggested feature kind."""
|
|
25
|
+
rows = []
|
|
26
|
+
for col in X.columns:
|
|
27
|
+
series = X[col]
|
|
28
|
+
rows.append(
|
|
29
|
+
{
|
|
30
|
+
"column": str(col),
|
|
31
|
+
"dtype": str(series.dtype),
|
|
32
|
+
"n_unique": int(series.nunique(dropna=True)),
|
|
33
|
+
"n_missing": int(series.isna().sum()),
|
|
34
|
+
"missing_pct": round(float(series.isna().mean()), 4),
|
|
35
|
+
"suggested_kind": infer_feature_kind(series.dtype) or "unsupported",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
return rows
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def profile_column(X: pd.DataFrame, column: str, *, top_n: int = 20) -> dict:
|
|
42
|
+
"""Detailed profile of a single column: cardinality, missingness, and a kind-specific view.
|
|
43
|
+
|
|
44
|
+
Numeric / datetime columns report min/max/quantiles; qualitative columns report their most
|
|
45
|
+
frequent modalities (capped at ``top_n``).
|
|
46
|
+
"""
|
|
47
|
+
if column not in X.columns:
|
|
48
|
+
raise ValueError(f"[inspection] column {column!r} not found.")
|
|
49
|
+
series = X[column]
|
|
50
|
+
kind = infer_feature_kind(series.dtype)
|
|
51
|
+
profile: dict = {
|
|
52
|
+
"column": column,
|
|
53
|
+
"dtype": str(series.dtype),
|
|
54
|
+
"suggested_kind": kind or "unsupported",
|
|
55
|
+
"n_unique": int(series.nunique(dropna=True)),
|
|
56
|
+
"n_missing": int(series.isna().sum()),
|
|
57
|
+
"missing_pct": round(float(series.isna().mean()), 4),
|
|
58
|
+
}
|
|
59
|
+
if kind in ("numerical", "datetime"):
|
|
60
|
+
non_null = series.dropna()
|
|
61
|
+
profile["min"] = str(non_null.min()) if not non_null.empty else None
|
|
62
|
+
profile["max"] = str(non_null.max()) if not non_null.empty else None
|
|
63
|
+
if kind == "numerical":
|
|
64
|
+
profile["quantiles"] = (
|
|
65
|
+
{str(q): round(float(non_null.quantile(q)), 6) for q in (0.0, 0.25, 0.5, 0.75, 1.0)}
|
|
66
|
+
if not non_null.empty
|
|
67
|
+
else {}
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
counts = series_value_counts(series, dropna=False)
|
|
71
|
+
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:top_n]
|
|
72
|
+
profile["top_values"] = [{"value": _key(k), "count": int(v)} for k, v in top]
|
|
73
|
+
return profile
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def feature_distribution(
|
|
77
|
+
X: pd.DataFrame,
|
|
78
|
+
column: str,
|
|
79
|
+
y: pd.Series | None = None,
|
|
80
|
+
*,
|
|
81
|
+
min_freq: float | None = None,
|
|
82
|
+
alpha: float = 0.05,
|
|
83
|
+
top_n: int = 50,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Distribution of a qualitative column's modalities, optionally against the target.
|
|
86
|
+
|
|
87
|
+
Reports per-modality count and frequency, the target rate when ``y`` is given, and — when
|
|
88
|
+
``min_freq`` is provided — flags modalities whose frequency is *significantly* below it
|
|
89
|
+
(Wilson upper bound, matching how the carvers decide rarity).
|
|
90
|
+
"""
|
|
91
|
+
if column not in X.columns:
|
|
92
|
+
raise ValueError(f"[inspection] column {column!r} not found.")
|
|
93
|
+
series = X[column]
|
|
94
|
+
counts = series_value_counts(series, dropna=False)
|
|
95
|
+
nobs = int(series.notna().sum())
|
|
96
|
+
rates = series_target_rate(series, y, dropna=True) if y is not None else {}
|
|
97
|
+
|
|
98
|
+
modalities = []
|
|
99
|
+
for value, count in sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:top_n]:
|
|
100
|
+
entry = {
|
|
101
|
+
"value": _key(value),
|
|
102
|
+
"count": int(count),
|
|
103
|
+
"frequency": round(count / nobs, 6) if nobs else 0.0,
|
|
104
|
+
}
|
|
105
|
+
if y is not None and value in rates:
|
|
106
|
+
entry["target_rate"] = round(float(rates[value]), 6)
|
|
107
|
+
if min_freq is not None and value is not None:
|
|
108
|
+
entry["rare"] = bool(is_significantly_below(count, nobs, min_freq, alpha))
|
|
109
|
+
modalities.append(entry)
|
|
110
|
+
|
|
111
|
+
return {"column": column, "n_observations": nobs, "n_modalities": len(counts), "modalities": modalities}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_nesting(X: pd.DataFrame, child: str, parents: list[str]) -> dict:
|
|
115
|
+
"""Checks that ``child`` rolls cleanly into ``parents`` (a many-to-one hierarchy).
|
|
116
|
+
|
|
117
|
+
For each consecutive (finer, coarser) level pair, verifies that every finer modality maps
|
|
118
|
+
to exactly one coarser modality. Returns whether the hierarchy is valid, the cardinality of
|
|
119
|
+
each level, and any violations (a finer modality spread across several coarser ones).
|
|
120
|
+
"""
|
|
121
|
+
levels = [child] + list(parents)
|
|
122
|
+
missing = [c for c in levels if c not in X.columns]
|
|
123
|
+
if missing:
|
|
124
|
+
raise ValueError(f"[inspection] columns not found: {missing}")
|
|
125
|
+
|
|
126
|
+
cardinalities = {col: int(X[col].nunique(dropna=True)) for col in levels}
|
|
127
|
+
violations = []
|
|
128
|
+
for finer, coarser in zip(levels[:-1], levels[1:]):
|
|
129
|
+
pairs = X[[finer, coarser]].dropna()
|
|
130
|
+
spread = pairs.groupby(finer)[coarser].nunique()
|
|
131
|
+
for value in spread[spread > 1].index:
|
|
132
|
+
parent_values = sorted(map(str, pairs.loc[pairs[finer] == value, coarser].unique()))
|
|
133
|
+
violations.append({"level": finer, "value": _key(value), "parent": coarser, "maps_to": parent_values})
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"child": child,
|
|
137
|
+
"parents": list(parents),
|
|
138
|
+
"valid": len(violations) == 0,
|
|
139
|
+
"cardinalities": cardinalities,
|
|
140
|
+
"violations": violations,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def datetime_reference_candidates(X: pd.DataFrame) -> list[dict]:
|
|
145
|
+
"""Summarises each datetime column (span + coverage) to help pick a reference.
|
|
146
|
+
|
|
147
|
+
A good fixed reference / anchor is typically the column with the widest, most-complete
|
|
148
|
+
coverage; another datetime column can also be referenced row-wise.
|
|
149
|
+
"""
|
|
150
|
+
candidates = []
|
|
151
|
+
for col in X.columns:
|
|
152
|
+
if not pd.api.types.is_datetime64_any_dtype(X[col]):
|
|
153
|
+
continue
|
|
154
|
+
non_null = X[col].dropna()
|
|
155
|
+
candidates.append(
|
|
156
|
+
{
|
|
157
|
+
"column": str(col),
|
|
158
|
+
"min": str(non_null.min()) if not non_null.empty else None,
|
|
159
|
+
"max": str(non_null.max()) if not non_null.empty else None,
|
|
160
|
+
"coverage_pct": round(float(X[col].notna().mean()), 4),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return candidates
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _key(value):
|
|
167
|
+
"""JSON-safe representation of a modality key (``None`` stays null, else stringified)."""
|
|
168
|
+
return None if value is None or pd.isna(value) else str(value)
|