EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Cyclical encoding for hours and weekdays

Cyclical encoding replaces a value that wraps round, such as an hour or a weekday, with its sine and cosine, so that the last value and the first sit next to each other.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9 · openpyxl 3.1

When to drop a feature instead of engineering it left the flight table with Dept_hour as a plain number from 0 to 23. A model reading that column believes 23:00 and 00:00 are 23 apart, when they are one hour apart. Every column that wraps round has the same fault, and this is the fix.

The gap the plain column creates

The hour column is used by every model that measures a distance or fits a slope. Both read the difference between two numbers, and on a column that wraps round that difference is wrong exactly once per cycle, at the join.

python
import pandas as pd

BASE = ("https://raw.githubusercontent.com/krishnaik06/5-Days-Live-EDA-and-Featur"
        "e-Engineering/main/Flight%20Prediction/")
train = pd.read_excel(BASE + "Data_Train.xlsx")   # 10683 rows, Price included
test = pd.read_excel(BASE + "Test_set.xlsx")      # 2671 rows, no Price
flights = pd.concat([train, test], ignore_index=True)
ExampleWhat the plain hour column claims about neighbouring hours
import pandas as pd

hour = pd.to_datetime(flights['Dep_Time'], format='%H:%M').dt.hour

for a, b in [(22, 23), (23, 0), (0, 1), (12, 13)]:
    raw = abs(a - b)
    real = min(raw, 24 - raw)          # the shorter way round the clock
    print(f"{a:3d} and {b:3d}   the column says {raw:3d}   the clock says {real:3d}")

print("\nlargest gap between neighbouring hours in the column:",
      max(abs(h - (h + 1) % 24) for h in range(24)))
  • Three of the four pairs are right. Neighbouring hours are one apart, as they should be.
  • 23 and 0 come out 23 apart. They are one hour apart on a clock, and they are the two hours a late night pattern most needs to join up.
  • That is the largest gap in the whole column, and it sits at the one place where the values are closest together in reality.

Putting the hours on a circle

A circle has no ends, so placing the 24 hours evenly round one removes the join. The position of each hour is then two numbers, how far round it is measured along one axis and along the other, which is exactly what the sine and the cosine give.

The 24 is the length of the cycle; use 7 for a weekday and 12 for a month
python
import numpy as np

# 2 * pi * h / 24 turns the hour into an angle: 0 at midnight, pi at noon
flights['Dept_hour_sin'] = np.sin(2 * np.pi * flights['Dept_hour'] / 24)
flights['Dept_hour_cos'] = np.cos(2 * np.pi * flights['Dept_hour'] / 24)

Both columns are needed. The sine alone cannot tell 6 from 18, because a sine takes the same value at both; the cosine separates them. Together they give every hour its own position, and the pair always satisfies sin squared plus cos squared equals 1.

Drawing the hours before and after

ExampleThe same 24 hours as one column and as two
import matplotlib.pyplot as plt
import numpy as np

hours = np.arange(24)
sin = np.sin(2 * np.pi * hours / 24)
cos = np.cos(2 * np.pi * hours / 24)

fig, ax = plt.subplots(1, 2, figsize=(10, 4.2))
ax[0].plot(hours, np.zeros(24), 'o', color='#3a6fd8', ms=6)
ax[0].annotate('', xy=(23, -0.35), xytext=(0, -0.35),
               arrowprops=dict(arrowstyle='<->', color='#d64541'))
ax[0].text(11.5, -0.55, 'the plain column puts them 23 apart', ha='center', color='#d64541')
ax[0].set_ylim(-0.8, 0.5); ax[0].set_yticks([]); ax[0].set_xticks([0, 6, 12, 18, 23])
ax[0].set_xlabel('Dept_hour as a plain number'); ax[0].set_title('one column')
ax[1].plot(sin, cos, 'o', color='#3a6fd8', ms=6)
for h in (0, 23):
    ax[1].plot([sin[h]], [cos[h]], 'o', color='#d64541', ms=9)
    ax[0].plot([h], [0], 'o', color='#d64541', ms=9)
    ax[0].annotate(str(h), (h, 0), xytext=(0, 14), textcoords='offset points',
                   ha='center', color='#d64541')
for side in ('left', 'right', 'top'):
    ax[0].spines[side].set_visible(False)
for h in (0, 6, 12, 18, 23):
    ax[1].annotate(str(h), (sin[h], cos[h]), xytext=(10, 6), textcoords='offset points')
ax[1].set_aspect('equal'); ax[1].set_xlabel('Dept_hour_sin'); ax[1].set_ylabel('Dept_hour_cos')
ax[1].set_title('two columns'); ax[1].margins(0.22)
fig.tight_layout()

print("sin of the first four hours:", sin[:4].round(4).tolist())
print("cos of the first four hours:", cos[:4].round(4).tolist())
print("sin squared plus cos squared:", np.unique((sin ** 2 + cos ** 2).round(10)).tolist())
Two panels: on the left the 24 hours as dots along a straight line with a red arrow showing hour 0 and hour 23 at opposite ends, and on the right the same hours as dots evenly spaced round a circle with 23 and 0 next to each other at the top.

Measuring the gap the two columns give

The proof is in what a distance now says. For each hour, the five nearest others are listed twice: once from the plain column, once from the sine and cosine pair.

ExampleNeighbours of hour 23, measured both ways
import numpy as np

hours = np.arange(24)
sin = np.sin(2 * np.pi * hours / 24)
cos = np.cos(2 * np.pi * hours / 24)
target = 23

plain = np.abs(hours - target)
cyclical = np.hypot(sin - sin[target], cos - cos[target])

print("five hours nearest to 23")
print("  from the plain column :", hours[np.argsort(plain, kind='stable')][1:6].tolist())
print("  from sin and cos      :", hours[np.argsort(cyclical, kind='stable')][1:6].tolist())

for a, b in [(23, 0), (23, 22), (11, 13), (0, 12)]:
    gap = np.hypot(sin[a] - sin[b], cos[a] - cos[b])
    print(f"{a:3d} and {b:3d}   plain {abs(a - b):3d}   cyclical {gap:.4f}")
  • The plain column says the neighbours of 23 are 22, 21, 20, 19, 18. Everything after midnight is invisible to it.
  • The pair says 22 and 0, then 21 and 1, then 20. The clock is joined up, and each hour is now as close to the one before it as to the one after it.
  • 23 and 0 come out at 0.2611, the same as 23 and 22. Two hours that are one step apart on the clock are one step apart in the data.
  • 0 and 12 come out at 2.0, the largest possible distance, which is the diameter of the circle. Midnight and noon are as far apart as two hours can be.

The same encoding for a weekday

Any column that wraps round takes the same pair, with the length of its own cycle in place of the 24: seven for a weekday, twelve for a month of the year, twelve again for a clock face, 365 for a day of the year.

ExampleA weekday on a seven step cycle
import numpy as np
import pandas as pd

when = pd.to_datetime(flights['Date_of_Journey'], format='%d/%m/%Y')
weekday = when.dt.weekday                      # Monday 0 ... Sunday 6
flights['Weekday_sin'] = np.sin(2 * np.pi * weekday / 7)
flights['Weekday_cos'] = np.cos(2 * np.pi * weekday / 7)

days = np.arange(7)
s, c = np.sin(2 * np.pi * days / 7), np.cos(2 * np.pi * days / 7)
print("Sunday to Monday   plain", abs(6 - 0), "  cyclical", round(float(np.hypot(s[6] - s[0], c[6] - c[0])), 4))
print("Monday to Tuesday  plain", abs(0 - 1), "  cyclical", round(float(np.hypot(s[0] - s[1], c[0] - c[1])), 4))
print("\nfirst three rows:")
print(flights[['Weekday_sin', 'Weekday_cos']].head(3).round(4).to_string())
  • Sunday to Monday reads 6 in the plain column and 0.8678 after the encoding.
  • Monday to Tuesday reads 1 and the same 0.8678. Every pair of neighbouring days is now the same distance apart, which is what a weekday means.
  • The two new columns replace the one, so the weekday costs two features instead of one, or instead of six if it had been one hot encoded.

Three ways to encode an hour

Plain number 0 to 23One hot, 24 columnsSine and cosine
Columns123 after dropping one2
23 next to 0no, 23 apartyes, every pair is equally faryes, one step apart
Keeps the orderyesnoyes
Works for a treeyes, trees split on thresholdsyesyes, but the split is harder to read
Works for KNN and a linear modelonly away from the joinloses the orderyes

The whole flight table, column by column

With the sine and cosine pair the chapter is finished. Four text columns went in and nine numeric features came out, two of which were in no column of the raw table.

Four rows, each with a text column on the left and the numeric columns it becomes on the right: Date_of_Journey becomes Date, Month, Year and Weekday; Dep_Time becomes Dept_hour, Dept_min and the sine and cosine pair; Duration becomes Duration_mins; and Route becomes nothing because Total_Stops already holds it.

Where cyclical encoding is used

  • Hour of the day and day of the week, in demand, traffic and fraud models, where late night behaviour runs across midnight.
  • Month and day of the year, where December and January are one month apart and a plain month number makes them eleven.
  • Any angle, such as a wind direction in degrees, where 359 and 1 are two degrees apart.
Watch out. Both columns are needed and they are useless on their own. Keeping only the sine merges 6 with 18 and 9 with 15, because a sine takes the same value twice per cycle. And a tree model gains little from the pair: it splits on thresholds, so it can already carve the hour column into bands. Reach for this when the model measures a distance or fits a slope.
Try it yourself
  • Encode the hour with a cycle of 12 instead of 24 and look at the circle. Morning and evening land on top of each other, which is what a clock face does.
  • Keep only Dept_hour_sin and list the hours nearest to 6. The pairing with 18 appears at once.
  • Build the same pair for Dept_min on a cycle of 60 and check whether the nearest neighbours of minute 55 now include minute 0.

You understood something today that you didn't yesterday.