Where this week sits, and what you will be able to do by the end of it
Week 1 gave you chart types and categorical variables. Week 2 gave you populations, samples and the descriptive statistics that summarise discrete data. This week we handle continuous numerical variables, where every observation is effectively unique and counting distinct values no longer works.
| Week | Topic |
|---|---|
| 1 | Types of variable. Visualisation for categorical variables. |
| 2 | Population and sample. Descriptive statistics. Visualisation for discrete. |
| 3 | Visualisation for continuous: histogram, cumulative relative frequency, boxplots |
| 4 | Pictograms, scatter plots, correlation |
| 5 | Group presentations / linear trend line |
| 6–7 | Time series; multivariate data |
By the end of this lecture and the accompanying notebook you should be able to:
Everything in this lecture uses data that is already installed with R. Type the name and it is there — no files, no working directory, no import errors.
| Object | What it is | Size | Why we use it |
|---|---|---|---|
airquality | Daily air measurements, New York, 1973 | 153 rows | Right-skewed ozone; contains missing values |
faithful | Old Faithful geyser eruptions | 272 rows | Strongly two-peaked — breaks naive summaries |
rivers | Lengths of major North American rivers | 141 values | Extreme right tail; motivates log scales |
precip | Annual rainfall, 70 US cities | 70 values | Mild left skew |
chickwts | Chick weight by feed supplement | 71 rows | Six groups — side-by-side boxplots |
# Nothing to install, nothing to load.
str(airquality)
head(faithful)
?airquality # every built-in dataset has a help pageIn Week 2 you visualised discrete data with bar charts, because the variable took a manageable number of distinct values and you could count how often each one occurred.
A continuous variable does not work that way. Measure fuel use, ozone concentration or waiting time finely enough and almost every observation is unique, so every "bar" has a count of one. A bar chart of continuous data is just a picture of your measurement precision.
In R, length(unique(airquality$Ozone)) returns 68
distinct values across 116 measurements. A bar chart would need 68 bars, most of
them height 1 or 2. A histogram needs about 9.
Stem-and-leaf plots: a histogram you can read the numbers off
A stem-and-leaf plot splits every number into two parts. The leading digits become the stem; the final digit becomes the leaf. Leaves are then listed against their stem.
Stem = 7, leaf = 8. Written as
7 | 8. Add 72, 75 and 79 and the row becomes 7 | 2 5 8 9.
Turn the plot on its side and the rows form bars — it is a histogram whose bin widths are powers of ten. The difference is that no information is thrown away: you can read every original observation back out of the plot.
stem() in RBase R provides stem(). It prints to the console rather than drawing a
graphic, so it is text you can copy into a report.
stem(airquality$Temp)The decimal point is at the | 56 | 0000 58 | 0000 60 | 000 62 | 000 64 | 0000 66 | 0000000 68 | 0000000 70 | 0000 72 | 00000000 74 | 00000000 76 | 0000000000000000 78 | 000000000000 80 | 0000000000000000 82 | 0000000000000 84 | 0000000000 86 | 000000000000 88 | 00000 90 | 00000 92 | 00000000 94 | 00 96 | 00
Temperature is recorded in whole degrees Fahrenheit, so every leaf is
0. R has chosen stems two units wide: the row 76 holds both
76° and 77°.
Read the shape from the lengths of the rows, exactly as you would read bar lengths.
The scale argument stretches or compresses the plot. Larger values
give more stems and finer detail.
stem(airquality$Temp, scale = 0.5) # fewer, wider stems
stem(airquality$Temp, scale = 2) # more, narrower stemsTry both. The choice of stem width has exactly the same effect on apparent shape as the choice of bin width in a histogram — a point we return to in Section 5.
4 | 2 2 5 8, with the decimal point at the |. Which values does it represent?stem() a poor choice for a variable with 50,000 observations?Mean, median, and the question of which one to trust
Two sets of symbols describe the same quantities, depending on whether you have measured everyone or only a sample. Getting them straight now saves confusion later.
| Population | Sample | |
|---|---|---|
| Size | N (may be infinite) | n |
| Mean | μ=N∑xi | xˉ=n∑xi |
| Variance | σ2=N∑(xi−μ)2 | s2=n−1∑(xi−xˉ)2 |
| Standard deviation | σ=σ2 | s=s2 |
var() and sd()
always use the sample formula.The balance point of the data. Every observation contributes, so every observation can move it.
Depends only on the order of the observations, not their magnitudes. Half the data lie below it.
Sort the data first, then:
x <- c(31, 34, 35, 36, 38, 40, 41)
mean(x) # 36.42857
median(x) # 36Take seven observations and change only the largest one. The mean moves by more than fifty units; the median does not move at all.
| Data | Mean | Median |
|---|---|---|
31, 34, 35, 36, 38, 40, 41 | 36.43 | 36 |
31, 34, 35, 36, 38, 40, 410 | 89.14 | 36 |
a <- c(31, 34, 35, 36, 38, 40, 41)
b <- c(31, 34, 35, 36, 38, 40, 410)
mean(a); median(a) # 36.42857 36
mean(b); median(b) # 89.14286 36b is larger than six of the seven
observations. As a description of "a typical value" it has failed completely. This is
what statisticians mean by calling the median robust and the mean
sensitive.Neither is wrong. If you are budgeting total fuel, insurance payouts or total revenue, you need the mean, because the mean multiplied by n gives the total. If you are describing a typical case, the median is usually the honest choice.
Daily ozone in airquality is strongly right-skewed: most days are low,
a handful are very high. The mean sits well above the median as a result.
oz <- airquality$Ozone[!is.na(airquality$Ozone)]
length(oz) # 116
mean(oz) # 42.12931
median(oz) # 31.5The mean of 42.1 exceeds the median of 31.5 by a third. That gap is the skew, expressed numerically. For a symmetric distribution the two would nearly coincide.
summary() and the missing-value trapsummary() gives five numbers plus the mean in one call — and, crucially,
tells you how many values are missing.
summary(airquality$Ozone)Min. 1st Qu. Median Mean 3rd Qu. Max. NA's 1.00 18.00 31.50 42.13 63.25 168.00 37
Thirty-seven of the 153 days have no ozone reading. Most R summary functions refuse to guess what to do about that:
mean(airquality$Ozone) # NA
mean(airquality$Ozone, na.rm = TRUE) # 42.12931NA is deliberate, not a bug. R is telling you
that the answer depends on a decision you have not made. Use na.rm = TRUE
only once you have checked why the values are missing — if the monitor
failed on the hottest days, dropping them will bias every statistic you compute.mean(airquality$Ozone) returns NA. What is R communicating?NA is mathematically correct behaviour. Adding na.rm = TRUE instructs R to compute the mean of the values that are present — a defensible choice, but one you should make consciously after considering why the data are missing.Range, quantiles, IQR, variance and standard deviation
The range is the distance from the smallest observation to the largest. It is the simplest measure of spread and the least reliable.
oz <- airquality$Ozone[!is.na(airquality$Ozone)]
range(oz) # 1 168 <- returns min and max, NOT the difference
max(oz) - min(oz) # 167 <- the range as a single numberrange() returns the two endpoints,
not the distance between them. If you want one number, subtract.The range depends on exactly two observations — the two most extreme, which are also the two most likely to be errors, and the two whose values are least reproducible if you collected the data again. Every other observation is ignored.
We need a measure of spread that uses the middle of the data instead. That is what quantiles give us.
A quantile is a cut point that divides sorted data into groups of specified proportion. The p-th quantile is the value below which a proportion p of the data lies.
| Proportion below | Percentage below | Name |
|---|---|---|
| 0.25 | 25% | Lower quartile, Q1 |
| 0.50 | 50% | Median, Q2 |
| 0.75 | 75% | Upper quartile, Q3 |
Quartiles split the data into four equal parts; deciles into ten; percentiles into a hundred. They are all the same idea at different resolutions.
The interquartile range is the width of the middle half of the data:
Unlike the range, it is computed from the bulk of the data and is completely unaffected by however extreme the most extreme observations happen to be. Move the largest value from 168 to 168,000 and the IQR does not change.
oz <- airquality$Ozone[!is.na(airquality$Ozone)]
quantile(oz, 0.25) # 18.00
quantile(oz, 0.75) # 63.25
IQR(oz) # 45.25
# equivalently
quantile(oz, 0.75) - quantile(oz, 0.25)So the central half of the ozone readings spans 45.25 ppb, from 18 to 63.25. That is a far more useful description of typical variability than the range of 167, which is driven entirely by one exceptional day.
When p does not land exactly on an observation, R interpolates between the two neighbouring values. Its default method computes a position
then takes the h-th value in the sorted data, interpolating linearly when h is not a whole number.
Sorted data: 3, 7, 8, 12, 15, 21, 24, 30, so n=8. Find Q1.
x <- c(3, 7, 8, 12, 15, 21, 24, 30)
quantile(x, 0.25) # 7.75 -- matches the hand calculationNine different quantile definitions exist and textbooks disagree about
which to use; some define the position as p(n+1) instead, giving 7.25 here. R
implements all nine via the type argument. Small differences between
software packages usually trace back to this choice, not to an error.
Called with no probabilities, quantile() returns the five-number summary.
Pass a vector of probabilities to get exactly the cut points you want.
oz <- airquality$Ozone[!is.na(airquality$Ozone)]
quantile(oz)0% 25% 50% 75% 100% 1.00 18.00 31.50 63.25 168.00
# any probabilities you like
quantile(oz, c(0.10, 0.90))10% 90% 7.5 87.0
So 10% of days had ozone below 7.5 ppb and 10% were above 87 ppb. Note
quantile() has no na.rm default — pass
na.rm = TRUE if your vector still contains missing values.
Spread means distance from the centre. The obvious measure — average deviation from the mean — is useless, because the deviations always sum to exactly zero. Squaring them first solves that.
Take x=2,4,4,4,5,5,7,9, so n=8 and xˉ=5.
| xi | 2 | 4 | 4 | 4 | 5 | 5 | 7 | 9 | Sum |
|---|---|---|---|---|---|---|---|---|---|
| xi−xˉ | −3 | −1 | −1 | −1 | 0 | 0 | 2 | 4 | 0 |
| (xi−xˉ)2 | 9 | 1 | 1 | 1 | 0 | 0 | 4 | 16 | 32 |
var() and sd()Confirm the hand calculation, then use the built-in functions from here on.
x <- c(2, 4, 4, 4, 5, 5, 7, 9)
n <- length(x)
# the long way
d <- x - mean(x) # deviations
sum(d) # 0 <- always
sum(d^2) # 32
sum(d^2) / (n - 1) # 4.571429
# the short way
var(x) # 4.571429
sd(x) # 2.13809
sqrt(var(x)) # 2.13809 <- sd is just the square root of the varianceMean-correct → square → sum → divide by n−1 → that is the variance → square root → that is the standard deviation.
Four reasons, in increasing order of depth:
var() and sd() use
n−1. Excel's VAR.S does too, though VAR.P does not —
a classic source of mismatched answers between the two.The standard deviation is in the same units as the data, which makes it directly interpretable as a typical distance from the mean. For data with a roughly bell-shaped distribution, three benchmarks are worth memorising.
Here is the proportion of observations actually falling within one standard deviation of the mean, for five built-in datasets. The bell-curve prediction is 68%.
| Dataset | n | Mean | sd | Within 1 sd | Shape |
|---|---|---|---|---|---|
airquality$Temp | 153 | 77.88 | 9.47 | 66.7% | Roughly symmetric |
precip | 70 | 34.89 | 13.71 | 68.6% | Mild left skew |
faithful$waiting | 272 | 70.90 | 13.59 | 62.9% | Two peaks |
airquality$Ozone | 116 | 42.13 | 32.99 | 72.4% | Right skew |
rivers | 141 | 591.18 | 493.87 | 90.1% | Extreme right skew |
v <- rivers
mean(abs(v - mean(v)) <= sd(v)) # 0.9007092The rule works well for the two datasets closest to symmetric and fails badly for
rivers, where a handful of enormous values inflate the standard deviation so
much that 90% of rivers fall inside one sd of the mean.
Both measure spread. They answer slightly different questions and fail in different circumstances.
| Standard deviation | Interquartile range | |
|---|---|---|
| Uses | Every observation | The middle half only |
| Outliers | Highly sensitive | Unaffected |
| Pairs with | The mean | The median |
| Covers | About the central 2/3 (within ±1 sd) | Exactly the central 1/2 |
| Best when | Roughly symmetric, no extreme values | Skewed, or outliers present |
Because ±1 sd spans about two-thirds of the data while the IQR spans exactly one half, you should normally expect 2×sd to exceed the IQR. It does for all five datasets on the previous slide.
Changing units is a linear transformation, and its effect on each statistic is predictable.
| Transformation | Mean | Variance | Standard deviation |
|---|---|---|---|
| Add a constant c | xˉ+c | unchanged | unchanged |
| Multiply by a constant k | kxˉ | k2s2 | ∣k∣s |
Shifting slides the whole distribution along without stretching it, so spread is untouched. Scaling stretches it, and because variance is in squared units it scales by k2.
tempF <- airquality$Temp
tempC <- (tempF - 32) * 5/9 # shift by -32, then scale by 5/9
mean(tempF) # 77.88235 mean(tempC) # 25.49020
sd(tempF) # 9.46527 sd(tempC) # 5.25848
var(tempF) # 89.59133 var(tempC) # 27.65165
sd(tempF) * 5/9 # 5.25848 <- matches sd(tempC)
var(tempF) * (5/9)^2 # 27.65165 <- matches var(tempC)The −32 shift changed the mean but had no effect at all on the sd or the variance. Only the ×5/9 scaling did.
rivers, 90.1% of values lie within one standard deviation of the mean rather than the expected 68%. What does this indicate?rivers has an extreme right tail. A few very long rivers inflate the standard deviation so much that the interval mean ± 1 sd becomes wide enough to swallow 90% of the data. The correct response is to report the median and IQR instead.Binning continuous data, and the frequency-versus-density decision
A histogram divides the range of the data into intervals called bins, counts the observations in each, and draws a bar for each bin. Bars touch, because the underlying variable is continuous and the bins are adjacent intervals rather than separate categories.
The individual values. Once observations are pooled into a bin, the histogram records only how many landed there — a genuine trade of detail for legibility, and the reason stem-and-leaf still has a place.
A bar chart and a histogram look similar and mean different things. A bar chart's horizontal axis lists categories in whatever order you choose; a histogram's is a number line, so bar order and bar width both carry meaning.
hist() in Roz <- airquality$Ozone[!is.na(airquality$Ozone)]
hist(oz,
xlab = "Ozone (ppb)",
main = "Ozone concentration, 116 days")Always label the axes and give the plot a title. xlab, ylab
and main take character strings; the default labels are the R expression you
passed in, which is meaningless to anyone reading your report.
hist() can plot two different things on the vertical axis.
freq = TRUE (default)Easy to explain. Heights are whole numbers you can read directly.
freq = FALSEHeights are not counts. Instead, area equals proportion, and the total area is exactly 1.
hist(oz, freq = FALSE, xlab = "Ozone (ppb)", main = "")
# verify the total area is 1
h <- hist(oz, plot = FALSE)
sum(h$density * diff(h$breaks)) # 1Suppose we bin ozone finely at the low end and coarsely at the high end. The 50–100 bin is five times wider than its neighbours, so it accumulates a large count purely by being wide.
brk <- c(0, 10, 20, 30, 40, 50, 100, 200)
par(mfrow = c(1, 2))
hist(oz, breaks = brk, freq = TRUE, xlab = "Ozone", main = "(a) frequency")
hist(oz, breaks = brk, freq = FALSE, xlab = "Ozone", main = "(b) density")In panel (a) the highlighted 50–100 bin looks like the second-largest group in the dataset. In panel (b) the same bin is one of the shortest, because its 27 observations are spread thinly across a 50-unit interval.
The breaks argument controls bin count. R treats a single number as a
suggestion and rounds to convenient boundaries.
par(mfrow = c(1, 3))
hist(faithful$waiting, breaks = 5, main = "too few")
hist(faithful$waiting, breaks = 12, main = "about right")
hist(faithful$waiting, breaks = 60, main = "too many")With five bins the two peaks are smeared into one broad lump. With twelve they are unmistakable. With sixty, random noise in individual bins starts to look like structure.
Four descriptions cover most of what you will need to say about a distribution.
Skew is named for the direction of the tail, not the location of
the peak. rivers is right-skewed because its long thin tail extends to the
right, even though its tallest bar is on the left.
faithful$waiting records the gap in minutes between eruptions of the Old
Faithful geyser. Its numerical summary looks entirely unremarkable.
summary(faithful$waiting)Min. 1st Qu. Median Mean 3rd Qu. Max. 43.0 58.0 76.0 70.9 82.0 96.0
When one tail is extreme, a histogram on the original scale becomes a single tall bar next to a strip of near-empty space. Plotting the logarithm of the values spreads them out.
par(mfrow = c(1, 2))
hist(rivers, xlab = "length (miles)", main = "original scale")
hist(log10(rivers), xlab = "log10(length)", main = "log scale")On the log scale the distribution is close to symmetric, and structure that was compressed into the first bar becomes visible. This is why financial returns, city populations, incomes and file sizes are so often plotted on log axes.
The transformed values are logarithms, so a bar at 2.6 means 102.6≈400 miles. Label such axes carefully or your reader will misread them by orders of magnitude.
faithful$waiting has mean 70.9 and median 76.0. Why is neither an adequate summary?Reading proportions and quantiles straight off a curve
Instead of asking how many observations fall in an interval, ask how many fall at or below a value. Plot that proportion against the value and you get the empirical cumulative distribution function.
The curve starts at 0 on the left, ends at 1 on the right, and never decreases. It steps up by 1/n at every observation, which is why it looks like a staircase.
Shape is much harder to see. Peaks become steep sections and gaps become flat sections, which takes practice to read. Most analysts use both.
ecdf() in Roz <- airquality$Ozone[!is.na(airquality$Ozone)]
plot(ecdf(oz),
verticals = TRUE,
main = "Cumulative relative frequency",
xlab = "Ozone (x, ppb)",
ylab = "Proportion less than or equal to x",
ylim = c(0, 1))ecdf() returns a function, so you can also evaluate it directly
rather than only plotting it:
F <- ecdf(oz)
F(50) # 0.7068966 -> about 71% of days were at or below 50 ppb
F(100) # 0.9396552 -> about 94% were at or below 100 ppbThe ECDF makes quantiles geometric. To find the median, start at 0.5 on the vertical axis, travel right to the curve, then drop to the horizontal axis.
plot(ecdf(oz), verticals = TRUE, main = "", xlab = "Ozone (ppb)",
ylab = "Proportion <= x")
q <- quantile(oz, c(0.25, 0.5, 0.75))
abline(h = c(0.25, 0.5, 0.75), lty = 2, col = "red") # horizontal guides
abline(v = q, lty = 2, col = "red") # vertical guidesThe steepness of the curve is itself informative: the sharp rise between 10 and 40 ppb tells you most days are concentrated there, while the near-flat stretch beyond 100 tells you high-ozone days are rare.
par(mfrow = c(rows, cols)) splits the plotting area into a grid, and
subsequent plots fill it row by row. Stacking a histogram above its ECDF lets you match
features between the two.
par(mfrow = c(2, 1)) # 2 rows, 1 column
hist(oz, freq = FALSE, xlim = c(0, 180),
xlab = "", ylab = "Density", main = "Ozone: density and cumulative views")
plot(ecdf(oz), verticals = TRUE, xlim = c(0, 180),
xlab = "Ozone (x, ppb)", ylab = "Proportion <= x", main = "")
par(mfrow = c(1, 1)) # reset when finishedxlim on both
panels so the axes align and features line up vertically, and reset
par(mfrow = c(1, 1)) afterwards — the setting persists and will
silently split your next plot too.Where the histogram has a tall bar, the ECDF is steep. Where the histogram is flat, the ECDF is nearly horizontal. They are two views of the same information.
F <- ecdf(oz), the call F(50) returns 0.7069. What does this mean?quantile(oz, 0.5) would answer, and it returns 31.5.Five numbers, one picture, many groups at once
A boxplot condenses a distribution into five numbers, then draws them.
| Element | What it shows |
|---|---|
| Lower whisker end | Smallest observation still inside the lower fence |
| Bottom of box | Lower quartile, Q1 |
| Line inside box | Median |
| Top of box | Upper quartile, Q3 |
| Upper whisker end | Largest observation still inside the upper fence |
| Individual points | Observations beyond the fences, plotted one by one |
The box therefore spans the IQR and contains the central half of the data. The position of the median line within the box indicates skew: pushed towards the bottom means a longer upper tail.
Reading this plot: the median sits well below the centre of the box, and the upper whisker is much longer than the lower one. Both features say the same thing — the distribution is right-skewed, exactly as the histogram in Section 3 showed.
Whiskers do not simply run to the minimum and maximum. R first computes two fences:
Each whisker then extends only as far as the most extreme observation still inside its fence. Anything beyond is drawn as an individual point.
Q1=18, Q3=63.25, so IQR=45.25 and 1.5×IQR=67.875.
boxplot.stats(oz)$out # 135 168boxplot() in Rboxplot(oz, ylab = "Ozone (ppb)", main = "Daily ozone, New York 1973")
# horizontal often reads better under a histogram
boxplot(oz, horizontal = TRUE, xlab = "Ozone (ppb)")
# the underlying numbers
boxplot.stats(oz)$stats [1] 1.0 18.0 31.5 63.5 122.0 $n [1] 116 $out [1] 135 168
The $stats vector holds the five plotted values in order: lower whisker,
Q1, median, Q3, upper whisker. $out lists the flagged points and
$n the sample size.
The Q3 reported here is 63.5, while quantile(oz, 0.75) gave
63.25. boxplot() uses hinges rather than R's default quantile
definition — the same nine-definitions issue from Section 4.4. The difference is
never material, but it is worth recognising rather than hunting for a bug.
This is what boxplots are genuinely best at. The formula interface
y ~ g reads as "y broken down by g" and produces one box per group.
boxplot(weight ~ feed, data = chickwts,
ylab = "Weight (g)", xlab = "Feed supplement",
main = "Chick weight by feed type")Casein and sunflower produce the heaviest chicks; horsebean is clearly worst. Sunflower also has the tightest box, meaning the most consistent results — a comparison of spread that a bar chart of group means would have concealed entirely.
A boxplot shows five numbers. Anything not captured by those five numbers is invisible — and that includes the number of peaks.
Both panels show faithful$waiting. The boxplot is entirely well behaved:
a symmetric-looking box, whiskers reaching the extremes, and not a single flagged
outlier. The histogram beside it shows two separated groups.
faithful$waiting shows no outliers and a reasonably symmetric box. What can you conclude about the distribution?Choosing the right tool, and where we go next
| Question | Use | Why |
|---|---|---|
| What shape is this variable? | Histogram | Only plot that shows peaks, gaps and skew clearly |
| What are the actual values? | Stem-and-leaf | Preserves every observation; small n only |
| What proportion is below a threshold? | ECDF | Read the answer straight off the vertical axis |
| What is the 90th percentile? | ECDF or quantile() | No binning required, so no arbitrary choice |
| How do these six groups compare? | Side-by-side boxplots | Centre and spread on one common scale |
| Are there unusual observations? | Boxplot | Applies the 1.5 × IQR rule automatically |
summary(), plot a histogram, then a boxplot. Three commands, and you will have
caught skew, gaps, extreme values and missing data before doing anything else.| Function | Purpose | Worth remembering |
|---|---|---|
stem(x) | Stem-and-leaf plot | Prints as text; use scale to adjust |
summary(x) | Five-number summary + mean | Also reports the count of NAs |
mean(x), median(x) | Centre | Both need na.rm = TRUE if NAs present |
range(x) | Min and max | Returns two numbers, not their difference |
quantile(x, p) | Quantiles | p may be a vector |
IQR(x) | Interquartile range | Equals quantile(x,.75) - quantile(x,.25) |
var(x), sd(x) | Spread | Always the sample versions, dividing by n−1 |
hist(x) | Histogram | freq, breaks, xlab, main |
ecdf(x) | Cumulative relative frequency | Returns a function; wrap in plot() |
boxplot(x) | Boxplot | Formula form: boxplot(y ~ g, data = d) |
boxplot.stats(x) | The numbers behind a boxplot | $stats and $out |
par(mfrow = c(r, c)) | Multiple plots per figure | Reset to c(1, 1) afterwards |
A numerical summary compresses a distribution to a few numbers; a plot shows you what the compression threw away. Old Faithful is the case to remember — a perfectly ordinary mean, median, IQR and boxplot, concealing two entirely separate eruption regimes.
quakes, islands or
mtcars$mpg.Everything so far has described a single variable in isolation. Next week we ask how two numerical variables move together:
A preview: plot(faithful$eruptions, faithful$waiting).
The two clusters you saw in the histogram this week will separate cleanly in two
dimensions, and the reason for them becomes obvious.