Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions docs/user_guide/datetime/DatetimeOrdinal.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ Datetime ordinal with feature-engine
ordinal numbers. It works with variables whose dtype is datetime, as well as with
object-type variables, provided that they can be parsed into datetime format.

:class:`DatetimeOrdinal()` uses pandas `toordinal()` under the hood. The main
:class:`DatetimeOrdinal()` computes the same proleptic Gregorian ordinal that
Python's `toordinal()` returns, vectorized under the hood for speed. The main
functionalities are:

- It can convert multiple datetime variables at once.
Expand Down Expand Up @@ -111,6 +112,51 @@ We see the new ordinal feature in the output:
By default, :class:`DatetimeOrdinal()` drops the original datetime variable. To keep
it, you can set `drop_original=False`.

With polars
~~~~~~~~~~~

:class:`DatetimeOrdinal()` works the same way with polars dataframes:

.. code:: python

import polars as pl
from feature_engine.datetime import DatetimeOrdinal

toy_df = pl.DataFrame({
"var_date1": ["1989-05-15", "2020-12-01", "1999-01-20", "2002-02-14"],
"var_date2": ["2012-06-21", "1998-02-10", "2010-08-03", "2020-10-31"],
"other_var": [1, 2, 3, 4]
})

dtfs = DatetimeOrdinal(variables="var_date2")

df_transf = dtfs.fit_transform(toy_df)

df_transf

.. code:: text

shape: (4, 3)
┌────────────┬───────────┬───────────────────┐
│ var_date1 ┆ other_var ┆ var_date2_ordinal │
│ --- ┆ --- ┆ --- │
│ str ┆ i64 ┆ i64 │
╞════════════╪═══════════╪═══════════════════╡
│ 1989-05-15 ┆ 1 ┆ 734675 │
│ 2020-12-01 ┆ 2 ┆ 729430 │
│ 1999-01-20 ┆ 3 ┆ 733987 │
│ 2002-02-14 ┆ 4 ┆ 737729 │
└────────────┴───────────┴───────────────────┘

.. note::

For string variables, pandas leans on `dateutil` and can guess its way through
loosely-formatted or ambiguous dates (e.g. ``"May-1989"``, ``"06/21/2012"``).
Polars parses dates natively and needs the format to be unambiguous and
consistent across the column - ISO 8601 (e.g. ``"1989-05-15"``) parses
reliably, but looser formats may raise an error. If your dates arrive in a
looser format, convert them to a native `Date`/`Datetime` column upstream.

Calculate days from a start date
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand All @@ -135,9 +181,9 @@ The new feature now represents the number of days between `var_date2` and Januar

var_date1 other_var var_date2_ordinal
0 May-1989 1 903
1 Dec-2020 2 -4343
1 Dec-2020 2 -4342
2 Jan-1999 3 215
3 Feb-2002 4 3956
3 Feb-2002 4 3957


Missing timestamps
Expand All @@ -150,7 +196,8 @@ If `missing_values="raise"`, the transformer will raise an error if NaT values a
found in the datetime variables during `fit()` or `transform()`.

If `missing_values="ignore"`, the transformer will ignore NaT values, and the resulting
ordinal feature will contain `NaN` (or `pd.NA`) in their place.
ordinal feature will contain a missing value in their place - `NaN` (`float64`) for
pandas, and `null` (`Int64`) for polars, following each library's own convention.


Additional resources
Expand Down
169 changes: 131 additions & 38 deletions feature_engine/datetime/datetime_ordinal.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import List, Optional, Union
import datetime
from typing import List, Optional, Union

import pandas as pd
import narwhals as nw
import narwhals.dependencies as nwd
import numpy as np
from dateutil.parser import parse as _parse_datetime
from narwhals.typing import IntoDataFrame, IntoSeries
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

Expand Down Expand Up @@ -32,6 +36,12 @@
from feature_engine.variable_handling.check_variables import check_datetime_variables
from feature_engine.variable_handling.find_variables import find_datetime_variables

# datetime.date(1970, 1, 1).toordinal() - the proleptic Gregorian ordinal of the
# Unix epoch, used to convert epoch-based timestamps into the same "days since
# January 1, 0001" ordinal that datetime.date.toordinal() returns.
_UNIX_EPOCH_ORDINAL = 719_163
_MICROSECONDS_PER_DAY = 86_400_000_000


@Substitution(
return_empty=_return_empty_docstring,
Expand Down Expand Up @@ -116,6 +126,25 @@ class DatetimeOrdinal(TransformerMixin, BaseEstimator, GetFeatureNamesOutMixin):
0 1
1 2
2 3

With polars:

>>> import polars as pl
>>> from feature_engine.datetime import DatetimeOrdinal
>>> X = pl.DataFrame(dict(date = ["2023-01-01", "2023-01-02", "2023-01-03"]))
>>> dtf = DatetimeOrdinal(start_date="2023-01-01")
>>> dtf.fit(X)
>>> dtf.transform(X)
shape: (3, 1)
┌──────────────┐
│ date_ordinal │
│ --- │
│ i64 │
╞══════════════╡
│ 1 │
│ 2 │
│ 3 │
└──────────────┘
"""

def __init__(
Expand All @@ -133,14 +162,18 @@ def __init__(
f"Got {missing_values} instead."
)

self.start_date_: Optional[datetime.date]
if start_date is not None:
try:
self.start_date_ = pd.to_datetime(start_date)
except Exception as e:
raise ValueError(
f"start_date could not be converted to datetime. "
f"Got {start_date} instead. Error: {e}"
)
if isinstance(start_date, datetime.date):
self.start_date_ = start_date
else:
try:
self.start_date_ = _parse_datetime(start_date)
except Exception as e:
raise ValueError(
f"start_date could not be converted to datetime. "
f"Got {start_date} instead. Error: {e}"
)
else:
self.start_date_ = None

Expand All @@ -157,7 +190,7 @@ def __init__(
self.missing_values = missing_values
self.drop_original = drop_original

def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None):
"""
This transformer does not learn any parameter.

Expand All @@ -166,11 +199,11 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):

Parameters
----------
X: pandas dataframe of shape = [n_samples, n_features]
X: dataframe of shape = [n_samples, n_features]
The training input samples. Can be the entire dataframe, not just the
variables to transform.

y: pandas Series=None
y: Series=None
It is not needed in this transformer. You can pass y or None.
"""
# check input dataframe
Expand All @@ -184,38 +217,44 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
self.variables_ = check_datetime_variables(X, self.variables)

# check if datetime variables contains na
if self.missing_values == "raise":
# nw.col([]) errors on the polars backend, so skip when there's
# nothing to check (happens when return_empty=True found no variables).
if self.missing_values == "raise" and len(self.variables_) > 0:
_check_contains_na(X, self.variables_)

self.start_date_ordinal_: Optional[int]
if self.start_date_ is not None:
self.start_date_ordinal_ = self.start_date_.toordinal()
else:
self.start_date_ordinal_ = None

# save input features
self.feature_names_in_ = X.columns.tolist()
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
self.feature_names_in_ = list(X.columns)
else:
self.feature_names_in_ = nw.from_native(X, eager_only=True).columns

# save train set shape
self.n_features_in_ = X.shape[1]

return self

def transform(self, X: pd.DataFrame) -> pd.DataFrame:
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
"""
Calculate ordinal representation of datetime features and add them to the
dataframe.

Parameters
----------
X: pandas dataframe of shape = [n_samples, n_features]
X: dataframe of shape = [n_samples, n_features]
The data to transform.

Returns
-------
X_new: pandas dataframe, shape = [n_samples, n_features x n_df_features]
X_new: dataframe, shape = [n_samples, n_features x n_df_features]
The dataframe with the original variables plus the new features.
"""

# Check method fit has been called
check_is_fitted(self)

Expand All @@ -225,39 +264,93 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
# Check if input data contains same number of columns as dataframe used to fit.
_check_X_matches_training_df(X, self.n_features_in_)

is_pandas = nwd.is_pandas_dataframe(X)

# reorder variables to match train set
X = X[self.feature_names_in_]
if is_pandas is True:
X = X[self.feature_names_in_]
else:
X = (
nw.from_native(X, eager_only=True)
.select(self.feature_names_in_)
.to_native()
)

if len(self.variables_) == 0:
return X

# create a copy(to protect original data)
X_new = X.copy()

# check if dataset contains na
if self.missing_values == "raise":
_check_contains_na(X_new, self.variables_)
_check_contains_na(X, self.variables_)

for var in self.variables_:
# Convert to datetime, then to ordinal
datetime_series = pd.to_datetime(X_new[var])
# Handle NaT values: toordinal() raises ValueError for NaT
ordinal_series = datetime_series.apply(
lambda x: x.toordinal() if pd.notna(x) else pd.NA
# variables can be native Date/Datetime columns, or string/categorical
# columns holding parseable date values - the latter need parsing into
# a real datetime dtype before the ordinal can be computed.
nw_X = nw.from_native(X, eager_only=True)
schema = nw_X.schema
to_parse = [
var
for var in self.variables_
if not isinstance(schema[var], (nw.Date, nw.Datetime))
]
if len(to_parse) > 0:
nw_X = nw_X.with_columns(
nw.col(var).cast(nw.String).str.to_datetime() for var in to_parse
)

if self.start_date_ordinal_ is not None:
# Only apply offset if not NaT
ordinal_series = ordinal_series.apply(
lambda x: x - self.start_date_ordinal_ + 1 if pd.notna(x) else pd.NA
)
if is_pandas is True:
X = self._transform_pandas(nw_X.to_native())
else:
X = self._transform_narwhals(nw_X)

return X

X_new[str(var) + "_ordinal"] = ordinal_series
def _transform_pandas(self, X):
"""Vectorized ordinal computation via numpy datetime64[D] arithmetic.

if self.drop_original:
X_new.drop(self.variables_, axis=1, inplace=True)
Benchmarked ~3.5-12x faster than the narwhals-generic dt.timestamp path
at 10k-100k rows x 1-10 columns (the gap widens with more columns), so
pandas keeps its own numpy fast path here.
"""
new_columns = {}
for var in self.variables_:
days = X[var].to_numpy().astype("datetime64[D]")
na_mask = np.isnat(days)
ordinal = days.astype("int64") + _UNIX_EPOCH_ORDINAL
if self.start_date_ordinal_ is not None:
ordinal = ordinal - self.start_date_ordinal_ + 1
if na_mask.any():
# int64 arithmetic on the NaT sentinel can wrap around, but that's
# harmless - the masked slots are overwritten with NaN right after.
ordinal = ordinal.astype("float64")
ordinal[na_mask] = np.nan
new_columns[str(var) + "_ordinal"] = ordinal

# assign() still inserts columns one at a time internally, so it doesn't
# avoid fragmentation with many variables; building one DataFrame and
# joining it does (single insertion), same pattern as DecisionTreeFeatures.
X = X.join(type(X)(new_columns, index=X.index))
if self.drop_original is True:
X = X.drop(columns=self.variables_)
return X

def _transform_narwhals(self, nw_X):
"""Ordinal computation via narwhals' dt.timestamp, already vectorized and
fast enough on polars that a numpy round-trip wouldn't pay for itself."""
exprs = []
for var in self.variables_:
ordinal_expr = (
nw.col(var).dt.timestamp("us") // _MICROSECONDS_PER_DAY
+ _UNIX_EPOCH_ORDINAL
)
if self.start_date_ordinal_ is not None:
ordinal_expr = ordinal_expr - self.start_date_ordinal_ + 1
exprs.append(ordinal_expr.alias(str(var) + "_ordinal"))

return X_new
nw_X = nw_X.with_columns(*exprs)
if self.drop_original is True:
nw_X = nw_X.drop(self.variables_)
return nw_X.to_native()

def _get_new_features_name(self) -> List:
"""create the names for the new features."""
Expand Down
Loading