Is Your Model Out of Time? The Case of Cyclical Feature Encoding

: The Midnight Riddle
Think about this. Build a model to predict electricity demand or taxi pickups. So, you count the time (like minutes) from midnight. It's clean and simple. OK?
Now your model can see 23:59 (minute 1439 per day) again 00:01 (1 minute per day). For you, they are two minutes apart. In your model, they are too far apart. That's the midnight puzzle. And yes, your model is almost timeless.
Why is this happening?
Because most machine learning models treat numbers as straight lines, not circles.
Linear regression, KNNs, SVMs, and neural networks will handle numbers reasonably well, assuming that higher numbers are “more” than lower ones. They don't know that time flies. Midnight is a crime that will never forgive.
If you've ever added hour details to your model without success, wondering later why your model is struggling with day boundaries, it might be why.
Common Coding Failures
Let's talk in general terms. You've probably used one of them.
You write the hours as numbers from 0 to 23. Now there is an artificial cliff between 23:00 and 0:00. So, this model thinks that midnight is the biggest jump of the day. However, is midnight really any different at 11 PM than 10 PM from 9 PM?
Of course not. But your model doesn't know that.
Here is a representation of the hours when you are in “line” mode.
# Generate data
date_today = pd.to_datetime('today').normalize()
datetime_24_hours = pd.date_range(start=date_today, periods=24, freq='h')
df = pd.DataFrame({'dt': datetime_24_hours})
df['hour'] = df['dt'].dt.hour
# Calculate Sin and Cosine
df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24)
df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)
# Plot the Hours in Linear mode
plt.figure(figsize=(15, 5))
plt.plot(df['hour'], [1]*24, linewidth=3)
plt.title('Hours in Linear Mode')
plt.xlabel('Hour')
plt.xticks(np.arange(0, 24, 1))
plt.ylabel('Value')
plt.show()
What if we write in one heat for hours? Twenty-four binary columns. Problem solved, right? Well… a little. You fixed the artificial gap, but you lost the proximity. 2 AM is no closer to 3 AM than 10 PM.
He also exploded in size. In trees, that's annoying. For linear models, it may not work properly.
So, let's move on to another possibility.
- Solution: Trigonometric map
Here's the mental shift:
Stop thinking of time as a line. Think of it as a circle.
The 24-hour day returns to itself. So your coding should go with it, thinking in circles. Each hour is an equally spaced point on a circle. Now, to represent a point on a circle, you don't use a single number, but instead you use it two links: x again y.
This is where sine and cosine come into play.
The geometry behind it
Every angle on a circle can be mapped to a unique point using sine and cosine. This gives your model a smooth, continuous representation of time.
plt.figure(figsize=(5, 5))
plt.scatter(df['hour_sin'], df['hour_cos'], linewidth=3)
plt.title('Hours in Cyclical Mode')
plt.xlabel('Hour')

Here is the mathematical formula for calculating the day-hour cycles:

- First,
2 * π * hour / 24converts each hour into an angle. Midnight and 11 PM end up in almost the same place on the circle. - Then we have again cosine project that angle into two coordinates.
- Those two values together define an hour differently. Now 23:00 and 00:00 are closer in the feature space. It's exactly what you've been looking for all along.
The same concept applies to minutes, days of the week, or months of the year.
The code
Let's try with this dataset Appliances Energy Prediction [4]. We will try to improve the prediction using the Random Forest Regressor model (a tree-based model).
Candanedo, L. (2017). Appliances Energy Prediction [Dataset]. UCI Machine Learning Repository. Creative Commons 4.0 license.
# Imports
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import root_mean_squared_error
from ucimlrepo import fetch_ucirepo
Get the data.
# fetch dataset
appliances_energy_prediction = fetch_ucirepo(id=374)
# data (as pandas dataframes)
X = appliances_energy_prediction.data.features
y = appliances_energy_prediction.data.targets
# To Pandas
df = pd.concat([X, y], axis=1)
df['date'] = df['date'].apply(lambda x: x[:10] + ' ' + x[11:])
df['date'] = pd.to_datetime(df['date'])
df['month'] = df['date'].dt.month
df['day'] = df['date'].dt.day
df['hour'] = df['date'].dt.hour
df.head(3)
Let's create a quick model with line first, as our basis of comparison.
# X and y
# X = df.drop(['Appliances', 'rv1', 'rv2', 'date'], axis=1)
X = df[['hour', 'day', 'T1', 'RH_1', 'T_out', 'Press_mm_hg', 'RH_out', 'Windspeed', 'Visibility', 'Tdewpoint']]
y = df['Appliances']
# Train Test Split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Fit the model
lr = RandomForestRegressor().fit(X_train, y_train)
# Score
print(f'Score: {lr.score(X_train, y_train)}')
# Test RMSE
y_pred = lr.predict(X_test)
rmse = root_mean_squared_error(y_test, y_pred)
print(f'RMSE: {rmse}')
The results are in.
Score: 0.9395797670166536
RMSE: 63.60964667197874
Next, we'll code the cycle time components (day again hour) and retrain the model.
# Add cyclical hours sin and cosine
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24)
df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)
df['day_sin'] = np.sin(2 * np.pi * df['day'] / 31)
df['day_cos'] = np.cos(2 * np.pi * df['day'] / 31)
# X and y
X = df[['hour_sin', 'hour_cos', 'day_sin', 'day_cos','T1', 'RH_1', 'T_out', 'Press_mm_hg', 'RH_out', 'Windspeed', 'Visibility', 'Tdewpoint']]
y = df['Appliances']
# Train Test Split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Fit the model
lr_cycle = RandomForestRegressor().fit(X_train, y_train)
# Score
print(f'Score: {lr_cycle.score(X_train, y_train)}')
# Test RMSE
y_pred = lr_cycle.predict(X_test)
rmse = root_mean_squared_error(y_test, y_pred)
print(f'RMSE: {rmse}')
And the results. We see a 1% improvement in score and 1 point in RMSE.
Score: 0.9416365489096074
RMSE: 62.87008070927842
I'm sure this doesn't look like much, but let's remember that this toy example uses a simple out-of-the-box model with no data manipulation or cleaning. We mainly see the effect of sine and cosine transformations.
What's really happening here is that, in real life, electricity demand doesn't reset at midnight. And now your model is finally seeing that progress.
Why You Need Both Sine and Cosine
Don't give in to the temptation to use it we only have fouras it sounds enough. One column instead of two. Cleaner, right?
Unfortunately, it breaks the symmetry. On a 24-hour clock, 6 AM and 6 PM would produce the same sine value. Different times with the same encoding would be bad because the model now confuses the morning rush hour with the evening rush hour. So, it's not good unless you enjoy confused predictions.
Using both sine and cosine fixes this. Together, they give each hour a unique fingerprint on the circle. Think of it as latitude and longitude. You both need to know where you are.
Real World Impact and Results
So, does this help the models? Yes. Especially some.
Range-based models
KNN and SVM rely heavily on distance computation. Cycle encoding prevents spurious “long distances” from boundaries. Your neighbors become neighbors again.
Neural networks
Fast learning neural networks with smooth feature spaces. Cycle encoding eliminates sharp discontinuities at midnight. That usually means faster convergence and better stability.
Tree-based models
Gradient Boosted Trees like XGBoost or LightGBM can finally learn these patterns. The coding of the cycle gives them a starting point. If you care about performance and interpretation, it's worth it.
7. When Should You Use This?
Always ask yourself the question: Does this feature repeat in a cycle? If so, consider circular coding.
Common examples are:
- The hour of the day
- The day of the week
- Month of the year
- Wind direction (degrees)
- If it's a loop, you might try writing it as a loop.
Before You Go
Time is not just a number. It's a circular link.
If you treat it as a straight line, your model may stumble over boundaries and have difficulty understanding that dynamic as a cycle, something that repeats and has a pattern.
Rotational encoding with sine and cosine fixes this elegantly, preserves approximation, reduces artifacts, and helps models learn faster.
So the next time your guesswork looks a little weird about the day's changes, try this new tool you've learned about, and let it make your model shine the way it should.
If you liked this content, find my other works and contacts on my website.
GitHub Repository
Here is the entire code for this function.
References and further reading
[1. Encoding hours Stack Exchange]:
[2. NumPy trigonometric functions]:
[3. Practical discussion on cyclical features]:
[4. Appliances Energy Prediction Dataset]



