Skip to content

Commit 8edc831

Browse files
committed
creating own datetime parser
1 parent d8e38fd commit 8edc831

8 files changed

Lines changed: 274 additions & 342 deletions

File tree

feature_engine/datetime/datetime.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pandas as pd
66
from pandas.api.types import is_datetime64_any_dtype as is_datetime
77
from pandas.api.types import is_numeric_dtype as is_numeric
8+
from pandas.api.types import is_object_dtype, is_string_dtype
89
from sklearn.base import BaseEstimator, TransformerMixin
910
from sklearn.utils.validation import check_is_fitted
1011

@@ -38,13 +39,41 @@
3839
FEATURES_SUFFIXES,
3940
FEATURES_SUPPORTED,
4041
)
41-
from feature_engine.variable_handling._variable_type_checks import (
42-
_is_categorical_and_is_datetime,
43-
)
4442
from feature_engine.variable_handling.check_variables import check_datetime_variables
4543
from feature_engine.variable_handling.find_variables import find_datetime_variables
4644

4745

46+
def _index_is_categorical_and_is_datetime(index: pd.Index) -> bool:
47+
# This file is fully pandas-based (it casts with `pd.to_datetime` during
48+
# `transform()`), and this check only ever runs against a pandas `Index`
49+
# (narwhals has no `Index` concept), so it stays pandas-only rather than
50+
# routing through the narwhals-based `_variable_type_checks` helpers.
51+
is_object = is_object_dtype(index) or is_string_dtype(index)
52+
53+
if isinstance(index.dtype, pd.CategoricalDtype):
54+
categories_are_numeric = is_numeric(index.categories)
55+
if categories_are_numeric:
56+
return False
57+
try:
58+
return is_datetime(pd.to_datetime(index, utc=True))
59+
except Exception:
60+
return False
61+
62+
elif is_object:
63+
try:
64+
is_convertible_to_num = is_numeric(pd.to_numeric(index))
65+
except (ValueError, TypeError):
66+
is_convertible_to_num = False
67+
if is_convertible_to_num:
68+
return False
69+
try:
70+
return is_datetime(pd.to_datetime(index, utc=True))
71+
except Exception:
72+
return False
73+
74+
return False
75+
76+
4877
@Substitution(
4978
return_empty=_return_empty_docstring,
5079
feature_names_in_=_feature_names_in_docstring,
@@ -265,7 +294,8 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
265294
if not (
266295
is_datetime(X.index)
267296
or (
268-
not is_numeric(X.index) and _is_categorical_and_is_datetime(X.index)
297+
not is_numeric(X.index)
298+
and _index_is_categorical_and_is_datetime(X.index)
269299
)
270300
):
271301
raise TypeError("The dataframe index is not datetime.")
Lines changed: 59 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,94 @@
1+
from datetime import date, datetime
2+
13
import narwhals as nw
2-
import pandas as pd
3-
from pandas.api.types import is_object_dtype, is_string_dtype
4-
from pandas.core.dtypes.common import is_datetime64_any_dtype as is_datetime
5-
from pandas.core.dtypes.common import is_numeric_dtype as is_numeric
4+
from dateutil.parser import parse as _dateutil_parse
65

76
# ---------------------------------------------------------------------------
8-
# pandas-only implementation.
7+
# narwhals implementation, used for every backend (pandas, polars, etc.)
98
#
10-
# These functions rely on pandas' flexible, dateutil-backed `pd.to_datetime`
11-
# string guessing and on pandas' `object` dtype (which, unlike any narwhals
12-
# dtype, can hold arbitrary non-string Python objects). Neither has a
13-
# polars/narwhals equivalent, so they are kept exactly as they were before the
14-
# narwhals migration and are only ever called on pandas input. See the `_nw_*`
15-
# functions below for the polars/narwhals-backend equivalents.
9+
# Flexible date-string recognition (e.g. "01-Jan-2010", "10/11/12", not just
10+
# ISO-8601) is implemented directly on top of `dateutil` - the same library
11+
# pandas.to_datetime delegates to internally for this - so it works
12+
# identically regardless of the underlying dataframe library.
1613
# ---------------------------------------------------------------------------
1714

1815

19-
def is_object(s) -> bool:
20-
return is_object_dtype(s) or is_string_dtype(s)
21-
22-
23-
def _is_categorical_and_is_not_datetime(column: pd.Series) -> bool:
24-
# check for datetime only if the type of the categories is not numeric
25-
# because pd.to_datetime throws an error when it is an integer
26-
if isinstance(column.dtype, pd.CategoricalDtype):
27-
is_cat = _is_categories_num(column) or not _is_convertible_to_dt(column)
28-
29-
# check for datetime only if object cannot be cast as numeric because
30-
# if it could pd.to_datetime would convert it to datetime regardless
31-
elif is_object(column):
32-
is_cat = _is_convertible_to_num(column) or not _is_convertible_to_dt(column)
33-
34-
else:
35-
is_cat = False
36-
37-
return is_cat
16+
def _nw_is_date_or_datetime(dtype) -> bool:
17+
# nw.selectors.datetime() only matches Datetime, not Date, so this needs
18+
# its own explicit check.
19+
return isinstance(dtype, (nw.Date, nw.Datetime))
3820

3921

40-
def _is_categories_num(column: pd.Series) -> bool:
41-
return is_numeric(column.dtype.categories)
22+
_DATE_PARSE_DEFAULT_1 = datetime(1, 1, 1, 1, 1, 1)
23+
_DATE_PARSE_DEFAULT_2 = datetime(2, 2, 2, 2, 2, 2)
24+
_DATETIME_FIELDS = ("year", "month", "day", "hour", "minute", "second")
4225

4326

44-
def _is_convertible_to_dt(column: pd.Series) -> bool:
27+
def _looks_like_date_string(value: str) -> bool:
28+
# dateutil.parser.parse() fills in any date/time component that isn't
29+
# present in the string from a `default` datetime, so a bare number like
30+
# "20" "parses" successfully as day=20 - it would wrongly be treated as a
31+
# date. Parsing twice, with two defaults that differ in every field,
32+
# reveals which fields were actually present in the string: those are the
33+
# fields that agree between the two parses. Requiring at least 2 fields to
34+
# be corroborated this way rejects bare numbers while still accepting real
35+
# dates (including non-ISO formats like "01-Jan-2010") and bare times
36+
# (like "21:45:23").
4537
try:
46-
var = pd.to_datetime(column, utc=True)
47-
return is_datetime(var)
48-
except Exception:
38+
first = _dateutil_parse(value, default=_DATE_PARSE_DEFAULT_1)
39+
second = _dateutil_parse(value, default=_DATE_PARSE_DEFAULT_2)
40+
except (ValueError, OverflowError, TypeError):
4941
return False
5042

51-
52-
def _is_convertible_to_num(column: pd.Series) -> bool:
53-
try:
54-
ser = pd.to_numeric(column)
55-
except (ValueError, TypeError):
56-
ser = column
57-
return is_numeric(ser)
58-
59-
60-
def _is_categorical_and_is_datetime(column: pd.Series) -> bool:
61-
# check for datetime only if the type of the categories is not numeric
62-
# because pd.to_datetime throws an error when it is an integer
63-
if isinstance(column.dtype, pd.CategoricalDtype):
64-
is_dt = not _is_categories_num(column) and _is_convertible_to_dt(column)
65-
66-
# check for datetime only if object cannot be cast as numeric because
67-
# if it could pd.to_datetime would convert it to datetime regardless
68-
elif is_object(column):
69-
is_dt = not _is_convertible_to_num(column) and _is_convertible_to_dt(column)
70-
71-
else:
72-
is_dt = False
73-
74-
return is_dt
75-
76-
77-
# ---------------------------------------------------------------------------
78-
# narwhals implementation, used for every backend other than pandas (polars,
79-
# in practice).
80-
#
81-
# narwhals has no lenient/"try" cast (no `strict=False`, unlike raw polars)
82-
# and its `str.to_datetime()` requires ISO-8601 or an explicit `format=` - it
83-
# cannot reproduce pandas' dateutil-based guessing. So a string column such as
84-
# "01-Jan-2010" or "10/11/12" is not auto-detected as datetime for polars,
85-
# even though it is for pandas. ISO-8601 strings and native Date/Datetime
86-
# columns are detected correctly. Users can always pass `variables` explicitly
87-
# to sidestep this.
88-
# ---------------------------------------------------------------------------
89-
90-
91-
def _nw_is_date_or_datetime(dtype) -> bool:
92-
# nw.selectors.datetime() only matches Datetime, not Date, so this needs
93-
# its own explicit check.
94-
return isinstance(dtype, (nw.Date, nw.Datetime))
43+
corroborated = sum(
44+
1 for attr in _DATETIME_FIELDS if getattr(first, attr) == getattr(second, attr)
45+
)
46+
return corroborated >= 2
9547

9648

9749
def _nw_is_convertible_to_num(s: "nw.Series") -> bool:
50+
values = s.drop_nulls().to_list()
51+
if not values:
52+
return False
9853
try:
99-
s.cast(nw.String()).cast(nw.Float64())
100-
except Exception:
54+
for value in values:
55+
float(value)
56+
except (ValueError, TypeError):
10157
return False
10258
return True
10359

10460

10561
def _nw_is_convertible_to_dt(s: "nw.Series") -> bool:
106-
try:
107-
s.cast(nw.String()).str.to_datetime()
108-
except Exception:
62+
values = s.drop_nulls().to_list()
63+
if not values:
10964
return False
65+
for value in values:
66+
if isinstance(value, (date, datetime)):
67+
continue
68+
if not _looks_like_date_string(str(value)):
69+
return False
11070
return True
11171

11272

73+
def _nw_categories_are_numeric(s: "nw.Series") -> bool:
74+
return s.cat.get_categories().dtype.is_numeric()
75+
76+
11377
def _nw_is_categorical_and_is_not_datetime(s: "nw.Series") -> bool:
11478
if isinstance(s.dtype, nw.Enum):
11579
# an explicit, user-defined category set is an unambiguous categorical
11680
# signal, unlike a generic string column, so skip the datetime check
11781
return True
11882

11983
if isinstance(s.dtype, nw.Categorical):
120-
# polars categorical categories are always string-backed, unlike
121-
# pandas' pd.Categorical, which can have numeric categories
122-
return not _nw_is_convertible_to_dt(s)
123-
124-
if isinstance(s.dtype, nw.String):
84+
# check for datetime only if the categories are not numeric, because
85+
# a numeric-backed categorical (pandas-only - polars categories are
86+
# always string-backed) can never hold dates
87+
return _nw_categories_are_numeric(s) or not _nw_is_convertible_to_dt(s)
88+
89+
if isinstance(s.dtype, (nw.String, nw.Object)):
90+
# check for datetime only if the column cannot be cast as numeric,
91+
# because if it could, it would be a numeric column, not a date
12592
return _nw_is_convertible_to_num(s) or not _nw_is_convertible_to_dt(s)
12693

12794
return False
@@ -132,9 +99,9 @@ def _nw_is_categorical_and_is_datetime(s: "nw.Series") -> bool:
13299
return False
133100

134101
if isinstance(s.dtype, nw.Categorical):
135-
return _nw_is_convertible_to_dt(s)
102+
return not _nw_categories_are_numeric(s) and _nw_is_convertible_to_dt(s)
136103

137-
if isinstance(s.dtype, nw.String):
104+
if isinstance(s.dtype, (nw.String, nw.Object)):
138105
return not _nw_is_convertible_to_num(s) and _nw_is_convertible_to_dt(s)
139106

140107
return False

feature_engine/variable_handling/check_variables.py

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@
55
import narwhals as nw
66
import narwhals.dependencies as nwd
77
from narwhals.typing import IntoDataFrame
8-
from pandas.core.dtypes.common import is_numeric_dtype as is_numeric
98

109
from feature_engine.variable_handling._variable_type_checks import (
11-
_is_categorical_and_is_datetime,
1210
_nw_is_categorical_and_is_datetime,
1311
_nw_is_date_or_datetime,
1412
)
15-
from feature_engine.variable_handling.dtypes import DATETIME_TYPES
1613

1714
Variables = Union[int, str, List[Union[str, int]]]
1815

@@ -165,10 +162,9 @@ def check_datetime_variables(
165162
166163
Notes
167164
-----
168-
For pandas dataframes, string columns are parsed with pandas' flexible,
169-
dateutil-backed date guessing. For polars (and other non-pandas dataframes),
170-
only ISO-8601 strings and native `Date`/`Datetime` columns are recognised -
171-
polars has no equivalent flexible guesser.
165+
String columns are parsed with flexible, dateutil-backed date guessing, in
166+
addition to ISO-8601 strings and native `Date`/`Datetime` columns,
167+
regardless of the dataframe library backing `X`.
172168
173169
Examples
174170
--------
@@ -187,27 +183,18 @@ def check_datetime_variables(
187183
if isinstance(variables, (str, int)):
188184
variables = [variables]
189185

190-
if nwd.is_pandas_dataframe(X):
191-
# find non datetime variables, if any:
192-
non_datetime_vars = []
193-
for column in X[variables].select_dtypes(exclude=DATETIME_TYPES):
194-
if is_numeric(X[column]) or not _is_categorical_and_is_datetime(
195-
X[column]
196-
):
197-
non_datetime_vars.append(column)
198-
else:
199-
sub_X = nw.from_native(X, eager_only=True).select(variables)
200-
candidates = [
201-
column
202-
for column in sub_X.columns
203-
if not _nw_is_date_or_datetime(sub_X.schema[column])
204-
]
205-
non_datetime_vars = [
206-
column
207-
for column in candidates
208-
if sub_X.schema[column].is_numeric()
209-
or not _nw_is_categorical_and_is_datetime(sub_X[column])
210-
]
186+
sub_X = nw.from_native(X, eager_only=True).select(variables)
187+
candidates = [
188+
column
189+
for column in sub_X.columns
190+
if not _nw_is_date_or_datetime(sub_X.schema[column])
191+
]
192+
non_datetime_vars = [
193+
column
194+
for column in candidates
195+
if sub_X.schema[column].is_numeric()
196+
or not _nw_is_categorical_and_is_datetime(sub_X.get_column(column))
197+
]
211198

212199
if len(non_datetime_vars) > 0:
213200
raise TypeError(

0 commit comments

Comments
 (0)