Skip to content

Feature Types

Feature Types

A network does not see a column. It sees numbers, and numbers come with two properties whether you meant them or not: an order and a distance. 7 is bigger than 3, and it is four away from 3 and one away from 6. The network has no way to be told otherwise — those facts are built into arithmetic, and arithmetic is all it does.

So this chapter is not really about taxonomy. Naming a column "nominal" changes nothing. What changes things is the encoding, and choosing one is making a claim:

What an encoding is

Encoding a feature is asserting which comparisons between its values are meaningful.

Hand a network city = 12, and you have asserted that city 12 is larger than city 11 and adjacent to city 13. Hand it hour = 23, and you have asserted that 23:00 is as far from midnight as it is possible for two hours to be. Neither assertion is in your data. Both are in your encoding, and the model will believe both.

The taxonomy below is just a way of working out which assertions are safe for a given column.


The map

graph TD
    D[a column] --> Q1{"is it a<br/>quantity?"}
    Q1 -->|yes| N["<b>numerical</b><br/><small>order ✓ distance ✓</small>"]
    Q1 -->|no| Q2{"do the values<br/>have an order?"}
    Q2 -->|yes| O["<b>ordinal</b><br/><small>order ✓ distance ✗</small>"]
    Q2 -->|no| Q3{"does it<br/>wrap around?"}
    Q3 -->|yes| C["<b>cyclical</b><br/><small>distance ✓ but circular</small>"]
    Q3 -->|no| M["<b>nominal</b><br/><small>order ✗ distance ✗</small>"]
    D --> Q4{"is it an<br/>identifier?"}
    Q4 -->|yes| I["<b>id</b><br/><small>not a feature</small>"]
    D --> U["<b>unstructured</b><br/><small>text · image · audio · graph</small>"]

    classDef ok   fill:#e6f4ea,stroke:#3fb950,color:#14532d
    classDef warn fill:#fff4e5,stroke:#f0883e,color:#7c2d12
    classDef bad  fill:#fdeaea,stroke:#ff7b72,color:#7f1d1d
    classDef q    fill:#eef2f7,stroke:#8b949e,color:#1f2937
    class N,O,C ok
    class M warn
    class I bad
    class D,Q1,Q2,Q3,Q4,U q
Type Order real? Distance real? Safe encoding
Continuous — price, temperature yes yes scale it
Discrete / count — orders, rooms yes yes scale it, often after log1p
Ordinal — S/M/L, education level yes no integers, knowing you asserted equal spacing
Cyclical — hour, month, angle yes yes, but circular sin and cos
Nominal — city, colour, SKU no no one-hot, or an embedding
Binary — yes/no trivially trivially 0/1
Identifier — user id, row number no no not a feature

Seeing the claim

Pick an encoding for hour_of_day and look at what it asserts. The left panel is where the 24 hours land in the encoded space; the right is the distance the encoding claims between every pair, with the cell for 23:00 against midnight boxed in white.

The number at the bottom is the rank agreement between the distances the encoding claims and the real circular ones. An integer scores about 0.72 — it gets most pairs roughly right and is catastrophically wrong about the one that wraps. One-hot scores 0.00, because it claims every pair is equidistant. sin/cos scores 0.997.


1. Numerical

The easy case: the order is real and the distances are real. The only thing to repair is scale, because a column measured in the hundreds of thousands and a column measured in tens do not contribute equally to a gradient, no matter which one matters more to the problem.

from sklearn.preprocessing import StandardScaler
# inside a Pipeline — see the chapter overview for why

Two sub-cases worth separating:

  • Counts (n_orders, n_visits) are numerical, but almost always right-skewed: most rows near zero, a long tail to the right. log1p before scaling usually helps, and the distributions page covers when it does not.
  • Bounded quantities (percentages, probabilities, pixel intensities) already have a natural range, so min-max to \([0,1]\) is often more faithful than standardizing.

The full treatment of which scaler to choose is in preprocessing.


2. Ordinal

The order is real: small < medium < large. Integer encoding preserves it, and that is exactly right.

from sklearn.preprocessing import OrdinalEncoder
enc = OrdinalEncoder(categories=[['small', 'medium', 'large']])   # spell out the order

You asserted more than the order

0, 1, 2 says small→medium is the same step as medium→large. For shirt sizes, roughly true. For {mild, severe, critical} in a triage column, badly false — the jump to critical is not one unit. If the spacing matters and you do not know it, one-hot instead: you lose the order but stop inventing distances. Or encode both, and let the network decide which it wants.

Always pass the categories explicitly. OrdinalEncoder with no categories sorts alphabetically, which turns {small, medium, large} into large < medium < small.


3. Cyclical

The trap the panel above is about. Hour, day of week, month, wind direction, phase angle: values that wrap around, where the largest and the smallest are neighbours.

import numpy as np
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24)
df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)

Two columns instead of one, and the period goes in the denominator: 24 for hours, 7 for weekdays, 12 for months, 360 for degrees. You need both sin and cos — either alone is ambiguous, since \(\sin\) takes the same value at two different hours.


4. Nominal

No order at all. {Recife, Curitiba, Belém} has no "middle". The encoding must not invent one, and the cost of not inventing one is columns.

import pandas as pd
df = pd.get_dummies(df, columns=['city'], drop_first=False)

One column per category, all pairs equidistant. The honest choice below roughly 20–50 categories. Above that it becomes a very wide, very sparse block that the first layer must learn to compress anyway.

import torch.nn as nn
# 40 000 SKUs → a learned 32-dimensional vector each
emb = nn.Embedding(num_embeddings=40_000, embedding_dim=32)

The deep learning answer to high cardinality: the network learns where each category belongs, so similar categories end up near each other because the task says so, not because you decided. For the dimension, the published rules of thumb disagree with each other — \(k^{1/4}\) and \(1.6\,k^{0.56}\) are both in circulation — which is a good sign that none of them is doing much work. Start near 16 or 32 and tune it like any other hyperparameter.

from sklearn.preprocessing import TargetEncoder
# replaces each category by an average of y, smoothed toward the global mean

One column instead of forty thousand. It is also the single easiest way to leak: the encoding is computed from the labels, so fitting it before the split hands the answer to the model. It belongs inside the Pipeline, always — see leakage.

counts = X_train['city'].value_counts(normalize=True)
X['city_freq'] = X['city'].map(counts).fillna(0)

Replace the category by how common it is. Cheap, no leakage risk from labels, and surprisingly often useful — but it collapses every two categories that happen to be equally common into the same value.

The one that is never right

df['city'] = LabelEncoder().fit_transform(df['city'])   # ❌
LabelEncoder exists for encoding the target, not the features. Applied to a nominal input it manufactures an ordering out of alphabetical accident, and the lab below measures what that costs.


5. Identifiers are not features

user_id, transaction_id, row_number, patient_number. They are keys, not measurements — and the reason to be strict about it is that they work. Ids are assigned in blocks: by registration date, by source system, by hospital. So the id correlates with the target, the model finds it, the score improves, and nothing has been learned.

This is one of the documented leakage cases in the leakage chapter: in KDD Cup 2008, the patient id was among the most predictive features available.

Identifiers do have one proper use: defining the groups the split must respect, so that all rows of one patient land on the same side.


6. Unstructured

Type Shape How it becomes numbers Typical model
Text variable-length subword tokens → ids → embeddings Transformer
Image H × W × C pixels, scaled to \([0,1]\) or standardized per channel CNN, ViT
Audio T samples waveform, or a spectrogram (T × F) Conv1D, Transformer
Graph N nodes, E edges adjacency + per-node features GNN
Time series T × F windowed segments, features strictly backward-looking LSTM, TCN, Transformer

The encoding question does not go away here — it moves inside the model. A tokenizer is a claim about which character sequences are units of meaning; a spectrogram is a claim that frequency content matters more than phase. The difference is that these encodings are mostly standard and mostly learned, rather than chosen per column.


Practice

A column arrives. How do you hand it to the network? 0/0

Lab: what a wrong encoding costs

Step 1 — inventing an order that is not there

Each neighbourhood has its own price level, and the levels are in no particular order. Label encoding hands the network one number and asks it to learn a function that jumps arbitrarily at every integer; one-hot hands it one switch per category. Scored by \(R^2\) under 5-fold cross-validation.

categories label_int one_hot
10 0.795 0.940
40 0.163 0.963
100 0.077 0.959
"""An integer code is an order, and the network believes it.

Each neighbourhood has its own price level, and the levels are in no particular
order — neighbourhood 7 is not between neighbourhood 6 and neighbourhood 8 in
any sense at all. Label encoding hands the network a single number and asks it
to learn a function that jumps arbitrarily at every integer. One-hot hands it
one switch per category and asks for nothing.

Watch what happens as the number of categories grows: the harder the invented
ordering is to unlearn, the further the label-encoded model falls behind.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.model_selection import KFold, cross_val_score
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

N = 1500

model = lambda: make_pipeline(
    StandardScaler(), MLPRegressor(hidden_layer_sizes=(32,), max_iter=4000, random_state=0)
)
cv = KFold(5, shuffle=True, random_state=0)

print("| `categories` | `label_int` | `one_hot` |")
print("|---:|---:|---:|")
for k in (10, 40, 100):
    rng = np.random.default_rng(9)
    level = rng.normal(size=k) * 2.0            # each category sits where it sits
    category = rng.integers(0, k, N)
    price = level[category] + 0.4 * rng.normal(size=N)

    as_integer = category.reshape(-1, 1).astype(float)   # 0, 1, 2, … — an invented order
    as_one_hot = np.eye(k)[category]                     # one switch per category

    r2 = lambda X: cross_val_score(model(), X, price, cv=cv, scoring="r2").mean()
    print(f"| {k} | **{r2(as_integer):.3f}** | **{r2(as_one_hot):.3f}** |")

With 10 categories the network mostly copes: 0.795 against one-hot's 0.940. It has enough capacity to learn ten jumps in a single input. At 40 categories it collapses to 0.163, and at 100 to 0.077 — while one-hot sits at 0.96 throughout, because for one-hot nothing got harder: each category still has its own switch.

That is the shape of the damage. Label encoding does not fail loudly; it fails as a slow leak that gets worse the more categories you have, and the only symptom is a model that underperforms for no visible reason.

Step 2 — the hour that wraps

Demand follows the hour of the day, and the busy period runs across midnight. The model is asked about two hours it never saw in training: first the pair straddling the wrap, then a pair in the middle of the day. Scored by mean absolute error, against the noise floor of 0.279 that no model can beat.

encoding unseen_23_00 unseen_11_12
integer 0.671 0.450
sin_cos 0.356 0.303
one_hot 2.945 3.325
noise_floor 0.279 0.279
"""Midnight is next to 23:00, and only one of these encodings knows that.

Demand follows the hour of the day, and the busy period runs across midnight.
The model is then asked about two hours it never saw in training — first the
pair that straddles the wrap, then a pair in the middle of the day — and scored
by mean absolute error against the noise floor.

Three claims about geometry, three results:
  integer  23 and 0 are as far apart as two hours can be
  sin_cos  the hours lie on a circle, so 23 and 0 are neighbours
  one_hot  no hour is near any other, and an unseen hour is a column of zeros

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.metrics import mean_absolute_error
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

N, NOISE = 4000, 0.35

rng = np.random.default_rng(5)
hour = rng.integers(0, 24, N)
angle = 2 * np.pi * hour / 24
demand = 3.0 * np.cos(angle) + 1.0 * np.cos(2 * angle) + NOISE * rng.normal(size=N)

encodings = {
    "integer": hour.reshape(-1, 1).astype(float),
    "sin_cos": np.c_[np.sin(angle), np.cos(angle)],
    "one_hot": np.eye(24)[hour],
}
holdouts = {"unseen_23_00": [23, 0], "unseen_11_12": [11, 12]}


def error(X, held):
    test = np.isin(hour, held)
    fitted = make_pipeline(
        StandardScaler(), MLPRegressor(hidden_layer_sizes=(16,), max_iter=5000, random_state=0)
    ).fit(X[~test], demand[~test])
    return mean_absolute_error(demand[test], fitted.predict(X[test]))


print(f"| `encoding` | `{'` | `'.join(holdouts)}` |")
print("|---|---:|---:|")
for name, X in encodings.items():
    scores = " | ".join(f"**{error(X, held):.3f}**" for held in holdouts.values())
    print(f"| `{name}` | {scores} |")
floor = NOISE * np.sqrt(2 / np.pi)                 # E|noise|, the best any model can do
print(f"| `noise_floor` | {floor:.3f} | {floor:.3f} |")

Three encodings, three different failures:

  • sin_cos lands at 0.356 and 0.303, close to the 0.279 floor. The hours it never saw are surrounded by hours it did, so it interpolates along the circle.
  • integer is worse everywhere, and worst at the wrap: 0.671 against 0.450 in the middle of the day. Midnight has neighbours on only one side, because the encoding put the other side 23 units away.
  • one_hot is off by an order of magnitude — 2.945 and 3.325 — and this is not a subtlety. An unseen category is a column that is zero in every training row. The network never saw that input fire, so it has nothing to say. One-hot gives up the ordering, and with it any ability to generalize to a value it has not met.

What the lab is teaching

There is no encoding that is always right, and the failure modes are opposites. Integer encoding invents structure that is not there. One-hot refuses to assume any structure, which costs you every generalization the structure would have bought. The question is never "which encoding is best" — it is which comparisons between these values are actually meaningful, and then which encoding asserts those and only those.