Adding a periodic encoder to the DatetimeEncoder - #1235
Conversation
| 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) |
There was a problem hiding this comment.
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?
|
I finished the base implementation and split the I am working locally on the examples, and now I need to write all the tests |
jeromedockes
left a comment
There was a problem hiding this comment.
thanks a lot @rcap107 ! here is a first few comments
| "nanosecond", | ||
| ] | ||
|
|
||
| _DEFAULT_ENCODING_PERIODS = {"year": 4, "month": 30, "weekday": 7, "hour": 24} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
| 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" |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
| _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] |
| # parameters | ||
| if self.add_periodic: | ||
| _enc_attr = [attr for attr in self.__dict__ if attr.endswith("_encoding")] | ||
| for _enc_name in _enc_attr: |
There was a problem hiding this comment.
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
| 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) |
There was a problem hiding this comment.
I think the circular and spline encoders could do that themselves on the numpy output and we wouldn't need the where_row
There was a problem hiding this comment.
this
X_out[~self.not_nulls] = np.nan
works with pandas, but doesn't work with polars, is there another way?
|
The SplineEncoder and the CircularEncoder are not documented in the API page, and thus they don't render correctly in the docs: |
| integer. | ||
| """ | ||
|
|
||
| def __init__(self, period, n_splines=None, degree=3): |
There was a problem hiding this comment.
In scikit-learn design, it is very frowned upon to have a parameter without a default value
There was a problem hiding this comment.
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
There was a problem hiding this comment.
yep sorry about that @rcap107 . Indeed I had said there was no sensible default here, but yeah you can add it back
| return tags | ||
|
|
||
|
|
||
| class SplineEncoder(SingleColumnTransformer): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
| Period to be used as basis of the trigonometric function. | ||
| """ | ||
|
|
||
| def __init__(self, period): |
There was a problem hiding this comment.
Same comment about scikit-learn really liking defaults. Put 365 for instance
|
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. |
|
|
||
| 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. |
There was a problem hiding this comment.
If I follow things right, this is used only if "add_periodic" is True above. We should mention this.
| 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 |
There was a problem hiding this comment.
Same comment here: this is used only if "add_periodic" is True above
| from skrub import DatetimeEncoder | ||
|
|
||
| date_enc = DatetimeEncoder().fit_transform(date) | ||
| date_enc = DatetimeEncoder(resolution="hour").fit_transform(date) |
There was a problem hiding this comment.
Why did we have to add this?
I always try to have something in an example only if it is needed for didactic reasons
There was a problem hiding this comment.
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
Co-authored-by: Gael Varoquaux <gael.varoquaux@normalesup.org>
|
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
I would rather have a one-liner comment just above
|
jeromedockes
left a comment
There was a problem hiding this comment.
this is shaping up very nicely :) I didn't have time to finish the review but I'll get back to it tomorrow
| 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 |
There was a problem hiding this comment.
I think this part is outdated there is no "add_periodic" anymore
|
the failure is unrelated, I guess the model we were using in a test is not available on huggingface anymore |
Co-authored-by: Jérôme Dockès <jerome@dockes.org>
|
|
||
| Bug fixes | ||
| --------- | ||
| - Fixed a bug that caused the :class:`StringEncoder` to not work in presence of null values. |
jeromedockes
left a comment
There was a problem hiding this comment.
very cool @rcap107 , we're down to nitpicks now 🚀
| if self.add_day_of_year: | ||
| encoding_level = encoding_level + ["day_of_year"] | ||
| if self.add_weekday: | ||
| encoding_level += ["weekday"] |
There was a problem hiding this comment.
could you add a test to cover this line? thanks
| 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"] |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
|
|
||
| self.transformer_ = self._periodic_spline_transformer() | ||
|
|
||
| X_out = self.transformer_.fit_transform(sbd.to_numpy(X).reshape(-1, 1)) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
|
|
||
| @where_row.specialize("pandas") | ||
| def _where_row_pandas(obj, mask, other): | ||
| return obj.apply(pd.Series.where, **{"cond": mask, "other": other}) |
There was a problem hiding this comment.
just out of curiosity why not cond=mask, other=other?
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>
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>

Draft for #907
Main points:
SplineTransformerfrom scikit-learnQuestions:
Of course, tests and examples are all missing.