← All Posts
Deep Learning · Data Preparation and Preprocessing · Encoding Categorical Features

Cyclical Encoding (sin/cos)

At 23:00, midnight is one hour away. As ordinary numbers, however, 23 and 0 are 23 units apart. Cyclical encoding represents position within a repeating cycle so the end meets the beginning.

It is useful for a known cycle such as hour of day or day of week. It does not make arbitrary categories cyclical, and it does not represent elapsed time across different days.

Put the hours around a circle

Place midnight at the top and advance clockwise through a 24-hour day. Use the point’s horizontal and vertical coordinates as two input features. Nearby hours have nearby coordinates, including the pair 23:00 and 00:00.

Midnight is a neighbor, not a distant endpoint A 24-hour circleThe selected hour 23 is close to midnight. A straight line joins their positions on the unit circle. 0 · midnight61218
Horizontal · sine−0.259
Vertical · cosine0.966
Hour 23 is 1 hour from midnight around the cycle. The raw number gap is 23; the straight-line distance between encoded points is 0.261.
The outline marks midnight; the filled dot marks the selected hour. Encoded distance is measured on a unit circle, not in hours.

Turn a position into an angle, then two coordinates

Let $x$ be the position in the cycle, $P$ its period, and $o$ the position chosen as the start. For hours, use $P=24$ and $o=0$. A fraction $(x-o)/P$ of one cycle corresponds to that fraction of a full turn, which is $2\pi$ radians:

$$\theta=2\pi\frac{x-o}{P},\qquad x_{\sin}=\sin\theta,\quad x_{\cos}=\cos\theta.$$

The outputs are two separate model columns. Hour 0 gives [0, 1]; hour 6 gives [1, 0]. Hour 24 returns to [0, 1], because it is the same phase on the next day.

Why use both sine and cosine?

Sine alone gives the same value to different positions. Hours 2 and 10 both have sine 0.5. Their cosine values are approximately +0.866 and −0.866, so the pair distinguishes them. Together, the two coordinates identify a phase within one cycle.

This pair also satisfies $x_{\sin}^2+x_{\cos}^2=1$: valid encoded positions lie on the unit circle. Euclidean distance between two such points reflects their shorter separation around the cycle. It is a chord length, not a replacement for actual time differences in hours.

Choose the period from the task, not the observed maximum

Hours 0 through 23 have period 24, not 23. Setting the period to 23 would place hour 23 on top of hour 0, incorrectly making two different hours identical. A training sample containing only working hours still uses the daily period of 24.

FeaturePosition conventionPeriod
Hour of day0 through 23, or fractional hours24
Day of weekMonday = 0 through Sunday = 67
Month of yearJanuary = 0 through December = 1112

For months numbered 1 through 12, use origin 1 to match this table. Shifting the origin rotates the circle; it does not change distances when both coordinates are retained. Preserve the same origin and feature order at prediction time.

Encode known cycles without fitting a vocabulary

The period and origin are fixed here, so there are no data-derived statistics to fit. The implementation wraps values into one cycle before applying sine and cosine. It rejects missing inputs so midnight is never silently used as a missing-value substitute.

Python: hours before and after midnight
import numpy as np

def encode_cycle(values, period, origin=0.0):
    values = np.asarray(values, dtype=float)
    if not np.isfinite(period) or period <= 0 or not np.isfinite(origin):
        raise ValueError("use a positive finite period and finite origin")
    if not np.isfinite(values).all():
        raise ValueError("handle missing or invalid inputs first")
    phase = np.remainder(values - origin, period) / period
    angle = 2 * np.pi * phase
    return np.column_stack([np.sin(angle), np.cos(angle)])

encoded = encode_cycle([23, 0, 1, 24], period=24)
print(encoded.round(3).tolist())
assert np.allclose(encoded[1], encoded[3])  # Hour 24 wraps to hour 0.
assert np.allclose((encoded ** 2).sum(axis=1), 1)
assert np.allclose(encode_cycle([-1], 24), encode_cycle([23], 24))
[[-0.259, 0.966], [0.0, 1.0], [0.259, 0.966], [0.0, 1.0]]

Negative positions are also meaningful under this arithmetic: −1 hour has the same phase as hour 23. Deciding whether such an input is valid belongs to your data schema; wrapping should not hide invalid source data.

A circular input does not guarantee a flexible daily pattern

A linear model with one sine/cosine pair can learn one smooth wave per cycle, with an adjustable height and phase. It cannot fit every possible hourly pattern. Two separated demand peaks, for example, may require more flexibility.

Constructed outcomes peak at hours 6 and 18. The best linear fit using one daily sine/cosine pair is a flat line at 10. Adding a second harmonic fits both peaks.
Representation and model capacity work together. These are constructed hourly values, not a measured dataset. Both curves are least-squares fits to all 24 hourly points. Dots show hours 0–23; the curves extend to hour 24 to close the daily cycle. One pair cannot express this symmetric two-peak pattern; adding the second harmonic can.
What is a second harmonic?

The first pair uses angle $\theta$. Adding $\sin(2\theta)$ and $\cos(2\theta)$ lets a linear model also represent a wave that repeats twice per original cycle. The figure’s constructed outcome is $10-5\cos(2\theta)$, so the added cosine can represent it exactly.

More harmonics add finer patterns and more parameters. A nonlinear model may represent complex patterns from the original pair, and periodic splines provide another smooth representation. See the periodic-spline example for a worked alternative. Choose flexibility through validation rather than adding harmonics indefinitely.

Keep the calendar information the task still needs

Hour-of-day encoding deliberately maps every midnight to the same coordinates. It loses which day it is. Retain a separate date or elapsed-time feature for long-term trends, and add weekday or holiday information when behavior depends on the calendar.

Use the clock appropriate to the behavior you are modeling, such as local hour for a local daily routine. Local clock phase and elapsed duration differ around daylight-saving changes; keep an actual timestamp when duration matters. Month positions also treat months as equally spaced categories, not equal-duration intervals.

Separate hour and weekday pairs in a linear model give additive effects. They do not automatically express a different hourly profile on weekends; that requires interactions or a suitable nonlinear model. Likewise, a cycle suggests proximity across its boundary, but whether that proximity helps prediction is an empirical question.

Evaluate future-facing tasks with chronological splits. A deterministic encoding prevents no leakage in other features or in the split itself. Handle missing timestamps deliberately through the missing-data workflow.

C++: the same period and coordinate order

The result is sine first, cosine second. The remainder adjustment makes negative offsets wrap consistently with the Python example.

Compile the cycle transform
#include <array>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <stdexcept>

std::array<double, 2> encode_cycle(double value, double period,
                                    double origin = 0.0) {
    if (!std::isfinite(value) || !std::isfinite(period) ||
        !std::isfinite(origin) || period <= 0)
        throw std::invalid_argument("invalid cycle input");
    double offset = std::fmod(value - origin, period);
    if (offset < 0) offset += period;
    const double angle = 2 * std::acos(-1.0) * offset / period;
    return {std::sin(angle), std::cos(angle)};
}

int main() {
    std::cout << std::fixed << std::setprecision(3);
    for (double hour : {23, 0, 1, 24}) {
        const auto pair = encode_cycle(hour, 24);
        std::cout << hour << ": " << pair[0] << ", " << pair[1] << "\n";
    }
}
23.000: -0.259, 0.966
0.000: 0.000, 1.000
1.000: 0.259, 0.966
24.000: 0.000, 1.000

References and further reading