Factors and types of variables
Nominal, ordinal, discrete, continuous — and how R stores categories
By the end of this lesson you will be able to:
- classify a variable as nominal, ordinal, discrete or continuous, and explain why the type matters;
- create factors with
factor(), controllinglevels,labelsandordered; - count categories with
table()and reorder levels withrelevel()andforcats; - avoid the classic
as.numeric(factor)trap and drop unused levels; - explain how a factor enters a statistical model through dummy coding.
library(forcats)1. Why do variable types matter?
Suppose that in a survey you code the answer “Which island do you live on?” as 1 = Biscoe, 2 = Dream, 3 = Torgersen. The column now contains numbers, and R will happily compute their mean: 1.8, say. But “the average island is 1.8” means nothing. The numbers are just labels.
Now consider a satisfaction question coded 1 = very unhappy … 5 = very happy. Here the order means something (4 is happier than 2), but is the step from 1 to 2 the same as from 4 to 5? Not necessarily.
And body mass in grams? A difference of 100 g means the same everywhere on the scale, and 0 g means “no mass”: we can average it, take ratios, everything.
The type of a variable decides which summaries, plots and statistical methods make sense. It is the first statistical concept of this course, and you will use it in every lesson that follows.
2. The four types of variables
Statisticians divide variables into two families, each with two members.
| Family | Type | Values are… | Penguin / R example | Sensible summaries |
|---|---|---|---|---|
| Categorical (qualitative) | Nominal | categories with no order | species, island, sex |
counts, proportions, mode |
| Ordinal | categories with a natural order | survey answers disagree < neutral < agree; age groups in esoph$agegp |
counts, proportions, median, percentiles | |
| Numerical (quantitative) | Discrete | counts: whole numbers, with gaps between possible values | number of insects in InsectSprays$count; number of chicks in a nest |
all of the above + mean, standard deviation |
| Continuous | measurements: any value in an interval, limited only by precision | bill_len, flipper_len, body_mass |
all of the above + mean, standard deviation |
A few remarks that often confuse students:
- A number is not always numerical. Postcodes, student IDs, and the island codes 1/2/3 above are nominal: they are names written with digits.
- Rounding does not make a variable discrete.
flipper_lenis stored as a whole number of millimetres, but flipper length is a continuous quantity that was simply measured to the nearest millimetre. - Binary variables (two categories, like
sexor yes/no answers) are a special case of nominal variables. They are very common, and in Logistic regression they become the outcome of a model. yearinpenguinsis a time point. With only three values (2007, 2008, 2009) it is often treated as a category in analysis.
- Can you do arithmetic with the values and get something meaningful? If no → categorical. Is there a natural order? Yes → ordinal; no → nominal.
- If yes → numerical. Are the values counts of something? Yes → discrete; otherwise (measurements) → continuous.
In R, numerical variables are stored as numeric or integer vectors (see Data types). Categorical variables are stored as factors. Look at how penguins stores its columns:
str(penguins)#> 'data.frame': 344 obs. of 8 variables:
#> $ species : Factor w/ 3 levels "Adelie","Chinstrap",..: 1 1 1 1 1 1 1 1 1 1 ...
#> $ island : Factor w/ 3 levels "Biscoe","Dream",..: 3 3 3 3 3 3 3 3 3 3 ...
#> $ bill_len : num 39.1 39.5 40.3 NA 36.7 39.3 38.9 39.2 34.1 42 ...
#> $ bill_dep : num 18.7 17.4 18 NA 19.3 20.6 17.8 19.6 18.1 20.2 ...
#> $ flipper_len: int 181 186 195 NA 193 190 181 195 193 190 ...
#> $ body_mass : int 3750 3800 3250 NA 3450 3650 3625 4675 3475 4250 ...
#> $ sex : Factor w/ 2 levels "female","male": 2 1 1 NA 1 2 1 2 NA NA ...
#> $ year : int 2007 2007 2007 2007 2007 2007 2007 2007 2007 2007 ...
species, island and sex are factors; the measurements are numbers.
A finer classification (due to S. S. Stevens) splits numerical variables into interval scales, where differences are meaningful but zero is arbitrary (temperature in °C: 20 °C is not “twice as hot” as 10 °C), and ratio scales, with a true zero (mass, length, income: 4 kg is twice 2 kg). Most measurements in this course are on a ratio scale. The distinction matters for statements about ratios and for some transformations (e.g. logarithms require positive values on a ratio scale).
3. Creating factors
A factor is a vector of categories. It stores each distinct category once — these are its levels — and then records, for each observation, which level it belongs to.
sex <- c("male", "female", "female", "male", "male")
class(sex)#> [1] "character"
sex_f <- factor(sex)
sex_f#> [1] male female female male male
#> Levels: female male
levels(sex_f)#> [1] "female" "male"
nlevels(sex_f)#> [1] 2
By default the levels are sorted alphabetically. You can choose them, and their order, with levels:
answer <- c("yes", "no", "yes", "yes")
factor(answer, levels = c("yes", "no"))#> [1] yes no yes yes
#> Levels: yes no
A value that is not among the levels becomes NA. This is a useful safety net against typos:
factor(c("yes", "no", "Yes"), levels = c("yes", "no"))#> [1] yes no <NA>
#> Levels: yes no
Codes with labels
Data are often stored as numeric codes. labels turns them into readable categories — the first label goes with the first level, and so on:
sex_code <- c(1, 0, 0, 1, 1)
factor(sex_code, levels = c(0, 1), labels = c("female", "male"))#> [1] male female female male male
#> Levels: female male
4. Ordered factors (ordinal variables)
For ordinal variables, set ordered = TRUE and give the levels in the right order:
satisfaction <- factor(
c("agree", "neutral", "strongly agree", "disagree", "agree"),
levels = c("strongly disagree", "disagree", "neutral",
"agree", "strongly agree"),
ordered = TRUE
)
satisfaction#> [1] agree neutral strongly agree disagree agree
#> 5 Levels: strongly disagree < disagree < neutral < ... < strongly agree
The < signs in the printed levels show the order. Now comparisons make sense:
satisfaction >= "agree"#> [1] TRUE FALSE TRUE FALSE TRUE
mean(satisfaction >= "agree") # proportion who agree or strongly agree#> [1] 0.6
max(satisfaction)#> [1] strongly agree
#> 5 Levels: strongly disagree < disagree < neutral < ... < strongly agree
With an unordered factor, >= would return NA with a warning, because “greater than” is meaningless for nominal categories. R also ships with ordinal data: in the esoph dataset, age groups are an ordered factor.
levels(esoph$agegp)#> [1] "25-34" "35-44" "45-54" "55-64" "65-74" "75+"
class(esoph$agegp)#> [1] "ordered" "factor"
5. Counting categories: table()
The natural summary of a categorical variable is a frequency table:
table(penguins$species)#>
#> Adelie Chinstrap Gentoo
#> 152 68 124
table(penguins$sex, useNA = "ifany") # show missing values too#>
#> female male <NA>
#> 165 168 11
table(penguins$species, penguins$island)#>
#> Biscoe Dream Torgersen
#> Adelie 44 56 52
#> Chinstrap 0 68 0
#> Gentoo 124 0 0
The two-way table counts penguins for each combination of species and island. The zeros are informative: Chinstrap penguins were found only on Dream, Gentoo only on Biscoe, while Adelie live on all three islands. useNA = "ifany" reveals 11 penguins with unknown sex, which table() hides by default. Frequency tables are developed in Frequency tables.
summary() on a factor gives the same counts. Note that the order of the levels decides the order of the rows in tables and of the bars in plots — which brings us to reordering.
6. Changing the order of levels
The first level has a special role: it is the reference category in statistical models (Section 8) and the first bar in plots. Base R’s relevel() changes the reference:
levels(relevel(penguins$species, ref = "Gentoo"))#> [1] "Gentoo" "Adelie" "Chinstrap"
The forcats package (part of the tidyverse) has a consistent family of fct_*() functions for everything else:
| Function | What it does |
|---|---|
fct_relevel(f, "a", "b") |
move the named levels to the front, in that order |
fct_infreq(f) |
order levels by frequency, most common first |
fct_rev(f) |
reverse the order |
fct_lump_n(f, n) |
keep the n most common levels, lump the rest into "Other" |
fct_recode(f, new = "old") |
rename levels |
fct_drop(f) |
drop unused levels |
species <- penguins$species
levels(fct_relevel(species, "Gentoo", "Chinstrap"))#> [1] "Gentoo" "Chinstrap" "Adelie"
levels(fct_infreq(species))#> [1] "Adelie" "Gentoo" "Chinstrap"
table(fct_lump_n(species, n = 1))#>
#> Adelie Other
#> 152 192
fct_infreq() is particularly useful before a bar chart: bars sorted by height are much easier to read (Categorical plots). fct_lump_n() helps when a variable has dozens of rare categories.
7. Two classic traps
Trap 1: converting a factor of numbers
Internally, a factor stores integer codes (1, 2, 3, …) pointing to its levels. as.numeric() returns those codes, not the numbers written in the labels:
dose <- factor(c("10", "20", "10", "5"))
dose#> [1] 10 20 10 5
#> Levels: 10 20 5
as.numeric(dose) # WRONG: the internal codes#> [1] 1 2 1 3
as.numeric(as.character(dose)) # RIGHT: go through text first#> [1] 10 20 10 5
The levels are "10", "20", "5" (sorted alphabetically, as text!), so the codes are 1, 2, 1, 3. No warning is given. This happens in practice when a numeric column is accidentally read as a factor because of a stray text value.
Trap 2: unused levels stick around
Filtering rows does not remove levels:
no_chinstrap <- penguins[penguins$species != "Chinstrap", ]
table(no_chinstrap$species) # Chinstrap is still a level, with 0 birds#>
#> Adelie Chinstrap Gentoo
#> 152 0 124
table(droplevels(no_chinstrap$species)) # drop the empty level#>
#> Adelie Gentoo
#> 152 124
Empty levels produce empty bars in plots and zero-count groups in tables or tests. Use droplevels() (on a vector or a whole data frame) or forcats::fct_drop() after filtering.
8. Why factors matter for models
Statistical models work with numbers. When a factor enters a model, R automatically turns it into dummy variables (also called indicator variables): one 0/1 column for each level except the reference level. You can see this with model.matrix(), here for one Adelie, one Gentoo and one Chinstrap penguin:
model.matrix(~ species, data = penguins)[c(1, 153, 277), ]#> (Intercept) speciesChinstrap speciesGentoo
#> 1 1 0 0
#> 153 1 0 1
#> 277 1 1 0
Adelie, the first level, is the reference: it has 0 in both dummy columns. A linear model of body mass on species then estimates the Adelie mean (the intercept) and the differences of the other species from Adelie:
coef(lm(body_mass ~ species, data = penguins))#> (Intercept) speciesChinstrap speciesGentoo
#> 3700.66 32.43 1375.35
The intercept is the mean mass of Adelie penguins, and speciesGentoo says Gentoo penguins are on average 1375 g heavier. Changing the reference with relevel() changes what the coefficients compare — but not the model’s predictions. You will study this in Multiple regression.
This is also why codes must be stored as factors: lm(mpg ~ cyl, data = mtcars) treats the number of cylinders as a continuous number (one slope), while lm(mpg ~ factor(cyl), data = mtcars) estimates a separate mean for 4, 6 and 8 cylinders.
- Treating codes as numbers. Island codes, IDs and postcodes are nominal; their mean is meaningless. Convert them with
factor(). as.numeric()on a factor returns internal codes. Useas.numeric(as.character(f)).- Forgetting
ordered = TRUE(or giving levels in alphabetical instead of logical order) for ordinal variables: “high” < “low” < “medium” is not what you meant. - Unused levels after filtering. Call
droplevels(). - Typos becoming
NA.factor(x, levels = ...)silently turns any value not inlevels(e.g."Yes"vs"yes") intoNA. Check withtable(x, useNA = "ifany").
9. Check your understanding
10. Exercises
Interactive (run in your browser)
★ Exercise 1 — codes to labels. In a survey, smoking status is coded 0 = never, 1 = former, 2 = current. Turn the codes into a factor with readable labels, and count each category.
levels = c(0, 1, 2) and labels = c("never", "former", "current").
smoke_f <- factor(smoke, levels = c(0, 1, 2),
labels = c("never", "former", "current"))
table(smoke_f) # never 4, former 2, current 2★★ Exercise 2 — an ordinal variable. Create an ordered factor size from the values below with levels small < medium < large. Then (a) find the largest value, (b) count how many are at least "medium".
Give the levels in increasing order and set ordered = TRUE. Compare with the text "medium".
size <- factor(x, levels = c("small", "medium", "large"), ordered = TRUE)
max(size) # large
sum(size >= "medium") # 5Without ordered = TRUE, max() would fail with an error: the maximum of nominal categories is not defined.
★★ Exercise 3 — debug the conversion. A dose column was read as a factor. The code below computes the mean dose, but the answer is wrong. Fix it.
Print levels(dose) and as.numeric(dose). What are those numbers?
mean(as.numeric(as.character(dose))) # 2.667as.numeric(dose) returns the internal codes of the levels "0.5" "1" "10" "2" (alphabetical order as text), i.e. 1 2 4 1 4 3, whose mean (2.5) has nothing to do with the doses.
★★ Exercise 4 — reorder and lump with forcats. (a) Reorder the levels of penguins$species by frequency. (b) Make Chinstrap the reference level. (c) Keep only the most common species and lump the others into "Other"; count them.
fct_infreq() orders by frequency; fct_relevel(sp, "Chinstrap"); n = 1.
levels(fct_infreq(sp)) # Adelie, Gentoo, Chinstrap
levels(fct_relevel(sp, "Chinstrap")) # Chinstrap, Adelie, Gentoo
table(fct_lump_n(sp, n = 1)) # Adelie 152, Other 192Practice on your own computer
★★★ Exercise 5 — types and models. (a) For each column of mtcars, decide whether it is nominal, ordinal, discrete or continuous (use ?mtcars). (b) Fit lm(mpg ~ cyl, data = mtcars) and lm(mpg ~ factor(cyl), data = mtcars). How many coefficients does each model have, and what do they mean? (c) Compare the coefficients of the second model with tapply(mtcars$mpg, mtcars$cyl, mean).
coef(lm(mpg ~ cyl, data = mtcars))
coef(lm(mpg ~ factor(cyl), data = mtcars))
tapply(mtcars$mpg, mtcars$cyl, mean)Continuous:
mpg,disp,hp,drat,wt,qsec. Discrete counts:cyl,gear,carb(with few values, often treated as categories). Nominal (binary):vs(engine shape) andam(transmission).The first model has 2 coefficients: it assumes that each extra cylinder changes
mpgby the same amount (a straight line). The second has 3: the intercept is the meanmpgof 4-cylinder cars, and the other two are the differences of 6- and 8-cylinder cars from it.Intercept ≈ 26.7 = mean of 4-cylinder cars; 26.7 − 6.9 ≈ 19.7 (6 cylinders); 26.7 − 11.6 ≈ 15.1 (8 cylinders).
Summary
| Function / idea | What it does |
|---|---|
| Nominal / ordinal / discrete / continuous | The four types of variables |
factor(x, levels, labels, ordered) |
Create a factor |
levels(), nlevels() |
Inspect levels |
table(x, useNA = "ifany") |
Frequency table |
relevel(f, ref) |
Change the reference level |
fct_relevel(), fct_infreq(), fct_rev(), fct_lump_n() |
Reorder or lump levels (forcats) |
droplevels(), fct_drop() |
Remove unused levels |
as.numeric(as.character(f)) |
Correct factor → number conversion |
model.matrix() |
Shows the dummy coding used by models |
- The type of a variable decides which summaries and methods make sense.
- Categorical variables are stored as factors; ordinal ones as ordered factors.
- The first level is the reference in models and the first bar in plots.
- Never call
as.numeric()directly on a factor.