Skip to content

Adding a periodic encoder to the DatetimeEncoder - #1235

Merged
rcap107 merged 49 commits into
skrub-data:mainfrom
rcap107:circular_encoding
Mar 21, 2025
Merged

Adding a periodic encoder to the DatetimeEncoder#1235
rcap107 merged 49 commits into
skrub-data:mainfrom
rcap107:circular_encoding

Conversation

@rcap107

@rcap107 rcap107 commented Feb 7, 2025

Copy link
Copy Markdown
Member

Draft for #907

Main points:

  • Adding transformers for hours in a day and days in a year
  • Adding the ordinal day (day in a year) as feature
  • Implement transformers using SplineTransformer from scikit-learn

Questions:

  • Should I also implement a CircularTransformer?
  • The main reason I am considering the CircularTransformer is because I am not sure what would be better for encoding days in a year. Should I just not bother, and keep SplineTransformer with a limited number of knots?
  • What should the default parameters be?
  • If we decide to encode the year, there is the problem of having both leap and non-leap years in a dataset. Is that a problem? I don't think it would affect the features that much.
  • Any more features that should be added in this PR?

Of course, tests and examples are all missing.

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
self._required_transformers[_case] = _t
if _case == "year":
# TODO: In theory we should check that the year is leap
_t = PeriodicEncoder(kind="circular", period=366)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it a bit surprising that the encoding is different for day and year? maybe it could be splines with 4 splines or something like that?

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py
@rcap107

rcap107 commented Feb 10, 2025

Copy link
Copy Markdown
Member Author

I finished the base implementation and split the PeriodicEncoder into SplineEncoder and CircularEncoder

I am working locally on the examples, and now I need to write all the tests

Comment thread skrub/_datetime_encoder.py Outdated
@rcap107
rcap107 marked this pull request as ready for review February 13, 2025 10:16
Comment thread skrub/_datetime_encoder.py Outdated

@jeromedockes jeromedockes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks a lot @rcap107 ! here is a first few comments

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py
Comment thread skrub/_datetime_encoder.py Outdated
"nanosecond",
]

_DEFAULT_ENCODING_PERIODS = {"year": 4, "month": 30, "weekday": 7, "hour": 24}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why 4 for year? shouldn't we set the period to 12 and use the month number or something like that? or set the period to 366 if we use the day of year?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the beginning I had 365 and the spline as default, but having 365 features for each year was too much, and I didn't have any better idea than setting it to a random number (4 seasons I guess)

Now that circular is the default, we can set it to 366

Comment thread skrub/_datetime_encoder.py Outdated
Select the strategy used to encode days in a week. By default, use a
CircularEncoder with period=7. If None, no encoding is performed.

hour_encoding : str, :class:``~CircularEncoder``, :class:``~SplineEncoder`` or None, default="circular"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the naming is inconsistent: does it indicate the time unit that gets encoded, or the one that defines the range?
for example here year_encoding encodes the position within the year, so the name corresponds to the range, but hour_encoding encodes the position within the day

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep the latter option, so hour_encoding will be sin(hour / 24 * 2* pi) whereas day_of_year_encoding will be sin(day_of_year / 366 * 2 * pi)

Comment thread skrub/_datetime_encoder.py Outdated
_enc_attr = [attr for attr in self.__dict__ if attr.endswith("_encoding")]
for _enc_name in _enc_attr:
_enc = self.__getattribute__(_enc_name)
_enc_case = _enc_name.split("_")[0]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why "case"?

Comment thread skrub/_datetime_encoder.py Outdated
# parameters
if self.add_periodic:
_enc_attr = [attr for attr in self.__dict__ if attr.endswith("_encoding")]
for _enc_name in _enc_attr:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detail: local variable names don't often start with an underscore. no special reason but it's one more character and I don't see what it adds

Comment thread skrub/_datetime_encoder.py Outdated
X_out = sbd.concat_horizontal(X_out, *_new_features)

# Censoring all the null features
X_out = sbd.where_row(X_out, self.not_nulls, _null_mask)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the circular and spline encoders could do that themselves on the numpy output and we wouldn't need the where_row

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this

X_out[~self.not_nulls] = np.nan

works with pandas, but doesn't work with polars, is there another way?

Comment thread skrub/_datetime_encoder.py
Comment thread skrub/_datetime_encoder.py Outdated
@GaelVaroquaux

Copy link
Copy Markdown
Member

The SplineEncoder and the CircularEncoder are not documented in the API page, and thus they don't render correctly in the docs:
https://output.circle-artifacts.com/output/job/cc1b279e-769f-4515-bd08-30d0ebebd0f6/artifacts/0/doc/reference/generated/skrub.DatetimeEncoder.html
image

Comment thread skrub/_datetime_encoder.py Outdated
integer.
"""

def __init__(self, period, n_splines=None, degree=3):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In scikit-learn design, it is very frowned upon to have a parameter without a default value

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed it after @jeromedockes suggested that having a default parameter that has nothing to do with the value to encode (e.g., 24 for month or year) would not be useful

I can add it back

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep sorry about that @rcap107 . Indeed I had said there was no sensible default here, but yeah you can add it back

Comment thread skrub/_datetime_encoder.py Outdated
return tags


class SplineEncoder(SingleColumnTransformer):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a design perspective, this class seems to me really like a super thin wrapper on the scikit-learn SplineTransformer.

Would it not be possible to get rid of it, and use the SplineTransformer? I would like to minimize almost-redundant functionnality

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I just fold the logic of both encoders back in the main DatetimeEncoder then? CircularEncoder is just wrapping a call to np.sin/np.cos

I was thinking that it could be useful to have periodic encoders for non-datetime features, but maybe I'm wrong

Comment thread skrub/_datetime_encoder.py Outdated
Period to be used as basis of the trigonometric function.
"""

def __init__(self, period):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about scikit-learn really liking defaults. Put 365 for instance

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
@GaelVaroquaux

Copy link
Copy Markdown
Member

I don't understand why, when I look at the generated example gallery, it looks like the relevant examples did not run:

I would really like to see the examples running. I don't have a feeling for merging a PR without the examples running.

Comment thread skrub/_datetime_encoder.py Outdated

year_encoding : str, :class:``~CircularEncoder``, :class:``~SplineEncoder`` or None, default="circular"
Select the strategy used to encode days in a year. By default, use a
CircularEncoder with period=365. If None, no encoding is performed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I follow things right, this is used only if "add_periodic" is True above. We should mention this.

Comment thread skrub/_datetime_encoder.py Outdated
CircularEncoder with period=365. If None, no encoding is performed.

month_encoding : str, :class:``~CircularEncoder``, :class:``~SplineEncoder`` or None, default="circular"
Select the strategy used to encode days in a month. By default, use a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here: this is used only if "add_periodic" is True above

Comment thread examples/03_datetime_encoder.py Outdated
from skrub import DatetimeEncoder

date_enc = DatetimeEncoder().fit_transform(date)
date_enc = DatetimeEncoder(resolution="hour").fit_transform(date)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we have to add this?

I always try to have something in an example only if it is needed for didactic reasons

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make it clear that's how we can specify the resolution, it's either that or adding a line to the previous paragraph saying that "hour" is the default resolution of the encoder

Comment thread examples/03_datetime_encoder.py
Comment thread examples/03_datetime_encoder.py Outdated
Co-authored-by: Gael Varoquaux <gael.varoquaux@normalesup.org>
@GaelVaroquaux

GaelVaroquaux commented Mar 19, 2025 via email

Copy link
Copy Markdown
Member

@jeromedockes jeromedockes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is shaping up very nicely :) I didn't have time to finish the review but I'll get back to it tomorrow

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
extracted_features_ : list of strings
The features that are extracted, a subset of ["year", …, "nanosecond",
"weekday", "total_seconds"]
"weekday", "total_seconds"]. If ``add_periodic=True``, the extracted

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this part is outdated there is no "add_periodic" anymore

Comment thread skrub/_datetime_encoder.py
Comment thread skrub/_datetime_encoder.py
@jeromedockes

Copy link
Copy Markdown
Member

the failure is unrelated, I guess the model we were using in a test is not available on huggingface anymore

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread CHANGES.rst Outdated

Bug fixes
---------
- Fixed a bug that caused the :class:`StringEncoder` to not work in presence of null values.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that was a different PR

@jeromedockes jeromedockes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very cool @rcap107 , we're down to nitpicks now 🚀

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated
if self.add_day_of_year:
encoding_level = encoding_level + ["day_of_year"]
if self.add_weekday:
encoding_level += ["weekday"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add a test to cover this line? thanks

Comment thread skrub/_datetime_encoder.py Outdated
if self.periodic_encoding is not None:
encoding_level = list(_DEFAULT_ENCODING_PERIODS.keys())[1 : idx_level + 1]
if self.add_day_of_year:
encoding_level = encoding_level + ["day_of_year"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC when we add day of year we get the spline/circular encoding of both the month and the day of year, that's probably redundant right? do you think that day_of_year will be rarely used? or maybe we should always exclude the month and include the day of year in the peridoic encoders?

@rcap107 rcap107 Mar 21, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the best solution would be, I'm already encoding the day of year over 12 splines so yes, either one is probably redundant.

I can see day_of_year being used on its own (encoding or not)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I would say month & day of year once encoded bring the same information (position within the year); I guess day of year in theory has a slightly better resolution (although that difference is probably not visible after smoothing by using only 12 splines). so I would say keep just 1 of the 2, whichever is easiest. and users who want periodic encoding and also the raw day of year are more of a special case I think, so they can use a columntransformer or feature union (for now, soon the expressions API), or we can think of a better way to accomodate that later if needed

Comment thread skrub/_datetime_encoder.py Outdated
Comment thread skrub/_datetime_encoder.py Outdated

self.transformer_ = self._periodic_spline_transformer()

X_out = self.transformer_.fit_transform(sbd.to_numpy(X).reshape(-1, 1))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the impression it would be easier to do the handling of null values here, on the numpy arrays, but maybe I'm wrong

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes looking at the code you might be right, but I don't think there's a point in moving the check here now that it's being addressed in the main transform

I'll keep it in mind for the next time though

@jeromedockes jeromedockes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good ! thanks a lot for all this hard work @rcap107 . this will be a great addition and make the tablevectorizer usable with linear models when there are dates

Comment thread skrub/_dataframe/_common.py Outdated

@where_row.specialize("pandas")
def _where_row_pandas(obj, mask, other):
return obj.apply(pd.Series.where, **{"cond": mask, "other": other})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just out of curiosity why not cond=mask, other=other?

@rcap107
rcap107 enabled auto-merge (squash) March 21, 2025 14:19
@rcap107
rcap107 merged commit bf2f396 into skrub-data:main Mar 21, 2025
@rcap107
rcap107 deleted the circular_encoding branch March 21, 2025 14:24
rcap107 added a commit to rcap107/skrub that referenced this pull request Apr 2, 2025
Co-authored-by: Gael Varoquaux <gael.varoquaux@normalesup.org>
Co-authored-by: Vincent M <maladiere.vincent@yahoo.fr>
Co-authored-by: Jérôme Dockès <jerome@dockes.org>
rcap107 added a commit that referenced this pull request Apr 2, 2025
Co-authored-by: Gael Varoquaux <gael.varoquaux@normalesup.org>
Co-authored-by: Vincent M <maladiere.vincent@yahoo.fr>
Co-authored-by: Jérôme Dockès <jerome@dockes.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants