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.
Files changed (79) hide show
  1. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/multiclass_carver.py +3 -0
  2. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/utils/base_carver.py +22 -0
  3. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/__init__.py +10 -0
  4. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/features.py +96 -33
  5. autocarver-7.3.3/AutoCarver/features/llm_qualifier.py +185 -0
  6. autocarver-7.3.3/AutoCarver/mcp/__init__.py +63 -0
  7. autocarver-7.3.3/AutoCarver/mcp/__main__.py +6 -0
  8. autocarver-7.3.3/AutoCarver/mcp/inspection.py +168 -0
  9. autocarver-7.3.3/AutoCarver/mcp/server.py +111 -0
  10. autocarver-7.3.3/AutoCarver/mcp/session.py +222 -0
  11. {autocarver-7.3.2 → autocarver-7.3.3}/PKG-INFO +13 -1
  12. {autocarver-7.3.2 → autocarver-7.3.3}/README.md +9 -0
  13. {autocarver-7.3.2 → autocarver-7.3.3}/pyproject.toml +8 -1
  14. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/__init__.py +0 -0
  15. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/__init__.py +0 -0
  16. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/binary_carver.py +0 -0
  17. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/continuous_carver.py +0 -0
  18. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/utils/__init__.py +0 -0
  19. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/carvers/utils/pretty_print.py +0 -0
  20. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/__init__.py +0 -0
  21. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/binary/__init__.py +0 -0
  22. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/binary/binary_combination_evaluators.py +0 -0
  23. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/binary/binary_target_rates.py +0 -0
  24. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/continuous/__init__.py +0 -0
  25. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/continuous/continuous_combination_evaluators.py +0 -0
  26. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/continuous/continuous_target_rates.py +0 -0
  27. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/__init__.py +0 -0
  28. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/combination_evaluator.py +0 -0
  29. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/combinations.py +0 -0
  30. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/target_rate.py +0 -0
  31. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/combinations/utils/testing.py +0 -0
  32. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/config.py +0 -0
  33. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/__init__.py +0 -0
  34. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/discretizer.py +0 -0
  35. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/__init__.py +0 -0
  36. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/categorical_discretizer.py +0 -0
  37. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/nested_discretizer.py +0 -0
  38. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/ordinal_discretizer.py +0 -0
  39. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/qualitatives/qualitative_discretizer.py +0 -0
  40. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/quantitatives/__init__.py +0 -0
  41. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/quantitatives/continuous_discretizer.py +0 -0
  42. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/quantitatives/quantitative_discretizer.py +0 -0
  43. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/__init__.py +0 -0
  44. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/base_discretizer.py +0 -0
  45. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/frequency_ci.py +0 -0
  46. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/multiprocessing.py +0 -0
  47. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/discretizers/utils/type_discretizers.py +0 -0
  48. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/__init__.py +0 -0
  49. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/categorical_feature.py +0 -0
  50. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/nested_feature.py +0 -0
  51. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/ordinal_feature.py +0 -0
  52. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/qualitatives/qualitative_feature.py +0 -0
  53. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/__init__.py +0 -0
  54. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/datetime_feature.py +0 -0
  55. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/numerical_feature.py +0 -0
  56. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/quantitatives/quantitative_feature.py +0 -0
  57. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/__init__.py +0 -0
  58. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/base_feature.py +0 -0
  59. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/grouped_list.py +0 -0
  60. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/features/utils/serialization.py +0 -0
  61. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/__init__.py +0 -0
  62. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/classification_selector.py +0 -0
  63. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/__init__.py +0 -0
  64. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/base_filters.py +0 -0
  65. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/qualitative_filters.py +0 -0
  66. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/filters/quantitative_filters.py +0 -0
  67. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/__init__.py +0 -0
  68. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/_vectorized.py +0 -0
  69. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/base_measures.py +0 -0
  70. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/qualitative_measures.py +0 -0
  71. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/measures/quantitative_measures.py +0 -0
  72. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/regression_selector.py +0 -0
  73. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/utils/__init__.py +0 -0
  74. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/utils/base_selector.py +0 -0
  75. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/selectors/utils/pretty_print.py +0 -0
  76. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/__init__.py +0 -0
  77. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/attributes.py +0 -0
  78. {autocarver-7.3.2 → autocarver-7.3.3}/AutoCarver/utils/dependencies.py +0 -0
  79. {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
- # class AutoFeatures(Features):
52
- # """TODO"""
53
-
54
- # __name__ = "AutoFeatures"
55
-
56
- # def __init__(self):
57
- # raise EnvironmentError(
58
- # f"[{self.__name__}] Should be instantiated with AutoFeatures.from_dataframe()"
59
- # )
60
-
61
- # def from_dataframe(self, X: pd.DataFrame) -> None:
62
- # """Automatically generates Features from an input DataFrame based on there data types"""
63
- # # initiating features
64
- # categoricals, ordinals, quantitatives, datetimes = ([],) * 4
65
-
66
- # # getting data types
67
- # for feature, dtype in X.dtypes:
68
- # str_dtype = str(dtype).lower()
69
- # # categorical feature
70
- # if dtype == "object":
71
- # categoricals += [feature]
72
- # # quantitative feature
73
- # elif str_dtype.startswith("int") or str_dtype.startswith("float"):
74
- # quantitatives += [feature]
75
- # # datetime feature
76
- # elif "date" in str_dtype or "time" in str_dtype:
77
- # datetimes += [feature]
78
- # # unknown data type
79
- # else:
80
- # warn(
81
- # f"[{self.__name__}] Ommited column {feature}, unknown data type {dtype}",
82
- # UserWarning,
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,6 @@
1
+ """Run the AutoCarver MCP server: ``python -m AutoCarver.mcp``."""
2
+
3
+ from AutoCarver.mcp.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)