| Title: | Standardised Statistical Comparison Workflows |
|---|---|
| Description: | Runs the applicable tests for a comparison in one call and returns standardised result tables. One-sample, two-group, multi-group, factorial and categorical workflows report parametric, rank-based and robust results side by side with effect sizes, confidence intervals and multiplicity-adjusted p-values. Supervised fits, embeddings, clustering and simulators follow the same result contracts. Methods include those of Welch (1947) <doi:10.1093/biomet/34.1-2.28>, Wilcoxon (1945) <doi:10.2307/3001968>, Mann and Whitney (1947) <doi:10.1214/aoms/1177730491>, Kruskal and Wallis (1952) <doi:10.1080/01621459.1952.10483441>, Friedman (1937) <doi:10.1080/01621459.1937.10503522>, Tukey (1949) <doi:10.2307/3001913>, Dunn (1964) <doi:10.1080/00401706.1964.10490181>, Yuen (1974) <doi:10.1093/biomet/61.1.165>, Brunner and Munzel (2000) <doi:10.1002/(SICI)1521-4036(200001)42:1%3C17::AID-BIMJ17%3E3.0.CO;2-U>, Algina, Keselman and Penfield (2005) <doi:10.1037/1082-989X.10.3.317>, DeLong, DeLong and Clarke-Pearson (1988) <doi:10.2307/2531595>, Sun and Xu (2014) <doi:10.1109/LSP.2014.2337313>, Pencina, D'Agostino, D'Agostino and Vasan (2008) <doi:10.1002/sim.2929>, and Pencina, D'Agostino and Steyerberg (2011) <doi:10.1002/sim.4085>. |
| Authors: | Wonseok Oh [aut, cre] (ORCID: <https://orcid.org/0009-0002-0687-8466>) |
| Maintainer: | Wonseok Oh <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 1.1.0 |
| Built: | 2026-10-06 11:41:00 UTC |
| Source: | https://github.com/hiows/statassist |
$cells is the canonical form, one row per cell, because that is the shape
that survives being written out as JSON with its labels attached. A table is
the shape to read it in, so it is built on request rather than stored twice
and left to drift.
## S3 method for class 'sa_categorical' as.table(x, ...)## S3 method for class 'sa_categorical' as.table(x, ...)
x |
A categorical comparison, as returned by
|
... |
Ignored, present for consistency with |
The rows dropped for a missing value or for a level outside category_lv are
already gone, so this is the table the tests were run on rather than a fresh
table() of the input.
A two-dimensional table() of counts, its dimnames named after the
two axes of the design.
res <- compare_categorical_groups( data.frame(smoker = rep(c("y", "n"), each = 30), grade = c(rep(c("high", "low"), c(22, 8)), rep(c("high", "low"), c(9, 21)))) ) as.table(res) ## Which is the table the tests were run on, so it feeds `stats` directly. stats::chisq.test(as.table(res))res <- compare_categorical_groups( data.frame(smoker = rep(c("y", "n"), each = 30), grade = c(rep(c("high", "low"), c(22, 8)), rep(c("high", "low"), c(9, 21)))) ) as.table(res) ## Which is the table the tests were run on, so it feeds `stats` directly. stats::chisq.test(as.table(res))
Divides, or on the log2 scale subtracts, the centre of the control group out
of each feature, so that the control lands on 1 (raw) or 0 (log2) and every
other observation reads as its distance from the control rather than as a
measurement of its own. The data that comes back is the data that went in
with the feats columns replaced, which is what lets the same call be handed
straight to a comparison or to draw_heatmap().
center_by_control( data, feats, group, group_lv, control_label = group_lv[1], fc_mean = c("arith", "geom"), input_scale = c("raw", "log2") )center_by_control( data, feats, group, group_lv, control_label = group_lv[1], fc_mean = c("arith", "geom"), input_scale = c("raw", "log2") )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of at least two group levels. Unlike in a
comparison, rows belonging to another level are kept: they are centred on
the same baseline and take no part in computing it, which leaves the result
the same length as the |
control_label |
The level whose centre is divided out. Defaults to
|
fc_mean |
Which centre of the control group is removed, |
input_scale |
The scale |
The arguments are the ones compare_two_groups() and
compare_multiple_groups() already take, in the same order and with the same
defaults, because the point of the function is that one set of arguments
describes both steps:
centred <- center_by_control(data, feats, group, group_lv,
input_scale = "log2")
compare_two_groups(centred, feats, group, group_lv, input_scale = "log2")
fc_mean is the comparison's own argument rather than a centring method of
this function's, and it is resolved the same way: the geometric mean by
default on the log2 scale, where it is the convention, and the arithmetic
mean otherwise. Passing the same value to both is what keeps the baseline
removed here identical to the centre the comparison divides in its effect
table.
Two or more levels are accepted, so the same call serves a two-group and a multi-group design. Only the control level takes part in the baseline; the rest are centred on it.
The data that was passed in, as a data.frame, with the feats
columns replaced by their control-relative values. Row count, row order,
row names and every column that is not a feature are left as they arrived.
A feature whose baseline could not be taken comes back as an NA column
and is named in a warning.
The transformation is one constant per feature applied to every row of it, so most of what a comparison reports does not move:
fold_change and log2fc
Unchanged. Both group centres are divided
by the same baseline, so their ratio survives it. This holds for all four
combinations of fc_mean and input_scale.
Unchanged, in every test family. On the log2 scale the baseline is subtracted, and the tests are shift invariant; on the raw scale it is divided, and dividing by a positive constant leaves the t statistic and every rank untouched.
Becomes 1. It is y_center in
compare_two_groups() and ref_center in compare_multiple_groups(),
and once it is 1 the other centre in the row is the fold change itself.
mean_diff, hl_shift and trim_diff
Unchanged on the log2 scale. On the raw scale they are divided by the baseline along with the data, since they are differences rather than ratios.
The reference centre lands on 1 because the observations used here and the
ones the comparison centres are the same. Under paired = TRUE a comparison
keeps complete pairs or complete subjects only, so if any are dropped its
control centre is taken on fewer rows than the baseline was and comes back
near 1 rather than at it. The ratios are unaffected either way.
compare_two_groups() and compare_multiple_groups(), whose
arguments this shares, and draw_heatmap(), which takes the result with
scale = "none" to show departure from the control rather than from each
feature's own mean.
feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") group_lv <- levels(iris$Species) ## setosa is the first level and so the control. Every measurement is now a ## multiple of the setosa mean, and setosa itself centres on 1. centred <- center_by_control(iris, feats, iris$Species, group_lv) colMeans(centred[iris$Species == "setosa", feats]) ## The comparison takes the centred data with its arguments unchanged, and ## reports the fold changes it reported before the centring. before <- compare_multiple_groups(iris, feats, iris$Species, group_lv, posthoc = FALSE, diagnose = FALSE) after <- compare_multiple_groups(centred, feats, iris$Species, group_lv, posthoc = FALSE, diagnose = FALSE) cbind(before = before$effect$log2fc, after = after$effect$log2fc) ## What did change is the reference centre, which is now 1 by construction, ## so `extreme_center` reads as the fold change on its own. after$effect[c("ref_center", "extreme_center", "fold_change")] ## On the log2 scale the baseline is subtracted instead, and the control ## centres on 0. Rounded, because the subtraction leaves floating point dust ## rather than a clean zero. d <- iris[iris$Species != "setosa", ] lg <- center_by_control(log2(d[feats]), feats, d$Species, c("versicolor", "virginica"), input_scale = "log2") round(colMeans(lg[d$Species == "versicolor", ]), 12) ## Pointing the baseline at the other level is one argument rather than a ## rewritten `group_lv`. virg <- center_by_control(iris, feats, iris$Species, group_lv, control_label = "virginica") colMeans(virg[iris$Species == "virginica", feats])feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") group_lv <- levels(iris$Species) ## setosa is the first level and so the control. Every measurement is now a ## multiple of the setosa mean, and setosa itself centres on 1. centred <- center_by_control(iris, feats, iris$Species, group_lv) colMeans(centred[iris$Species == "setosa", feats]) ## The comparison takes the centred data with its arguments unchanged, and ## reports the fold changes it reported before the centring. before <- compare_multiple_groups(iris, feats, iris$Species, group_lv, posthoc = FALSE, diagnose = FALSE) after <- compare_multiple_groups(centred, feats, iris$Species, group_lv, posthoc = FALSE, diagnose = FALSE) cbind(before = before$effect$log2fc, after = after$effect$log2fc) ## What did change is the reference centre, which is now 1 by construction, ## so `extreme_center` reads as the fold change on its own. after$effect[c("ref_center", "extreme_center", "fold_change")] ## On the log2 scale the baseline is subtracted instead, and the control ## centres on 0. Rounded, because the subtraction leaves floating point dust ## rather than a clean zero. d <- iris[iris$Species != "setosa", ] lg <- center_by_control(log2(d[feats]), feats, d$Species, c("versicolor", "virginica"), input_scale = "log2") round(colMeans(lg[d$Species == "versicolor", ]), 12) ## Pointing the baseline at the other level is one argument rather than a ## rewritten `group_lv`. virg <- center_by_control(iris, feats, iris$Species, group_lv, control_label = "virginica") colMeans(virg[iris$Species == "virginica", feats])
Grows a cluster out of every point that has at least min_pts neighbours within
eps of it, joining the neighbourhoods that overlap, and leaves the points that
never fell into one as noise. How many clusters there are is the answer rather
than the question, and it can be none.
cluster_dbscan( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, eps = NULL, min_pts = NULL )cluster_dbscan( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, eps = NULL, min_pts = NULL )
data |
A data.frame or a matrix in wide format, one row per sample and one column per feature. |
feats |
Column names to cluster on, or |
cluster_scale |
Which margin becomes the points being clustered:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation first. Both always apply to the columns of |
eps |
The radius of a neighbourhood, in the units of the matrix being
clustered, or |
min_pts |
How many points must be within |
The input is the wide format the comparison functions take: one row per sample
and one column per feature. Which margin of it becomes the thing being
clustered is cluster_scale, and design$point_type reports the answer.
An object of class sa_cluster, a plain list. The slots are
cluster_hclust()'s, with analysis "dbscan", fit the
dbscan::dbscan() object, and design$n_clusters and design$n_noise both
answers rather than arguments.
A point that never joined a neighbourhood gets cluster 0, which is
dbscan::dbscan()'s convention and this contract's. It has no silhouette, since
noise is not a cluster to be near or far from, and clusters has no row for it;
design$n_noise is the count. All points being noise is a possible answer and
means the density asked for is not present, which is usually eps being too
small or min_pts too large for the data.
eps is a radius in the units of the matrix being clustered, which is why there
is no default constant that could be right. Left as NULL it is derived from
the distances actually present: every point's distance to its min_pts - 1th
neighbour is collected, and eps is the 95th percentile of those. The rule
therefore reads as "assume about one point in twenty is noise, and set the
radius that leaves that many outside", which is a statement worth disagreeing
with rather than a number off a curve.
The quantity being read is the one dbscan::kNNdistplot() plots and the manual
procedure reads a knee off by eye. The knee is not what is taken, because
locating it arithmetically fails on a curve that rises gradually rather than
turning; the probe in Test/cursor_test/2026_08_15/ has the case. Either way it
is a heuristic: parameters$eps_source records whether the value was supplied
or derived, and a derived one is said out loud when it is used.
Note that this eps is a distance. cluster_snn() has an argument of the same
name that counts shared neighbours, because the algorithm it comes from named it
that; the two are not comparable.
cluster_scale = "samples", the default, puts one point per row of data, and
"features" puts one point per column. See cluster_hclust(), which documents
the same argument and why transposing data by hand is a different analysis.
cluster_snn(), the other density method here, which measures
closeness by how many neighbours two points share rather than by a radius, and
cluster_kmeans(), which is told the number of groups and places every point.
## Two well-separated blobs and a couple of strays. The count is not an ## argument, so finding two is the result rather than the setup. set.seed(1) blobs <- rbind( matrix(rnorm(60, mean = -3), ncol = 2), matrix(rnorm(60, mean = 3), ncol = 2), cbind(c(-12, 12), c(12, -12)) ) colnames(blobs) <- c("x", "y") res <- cluster_dbscan(blobs, scale = FALSE) res table(res$assignments$cluster) plot(blobs, col = res$assignments$cluster + 1L, pch = 16, main = "cluster 0, in black, is noise") ## On a planted two-group structure, with the radius left to be derived. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_dbscan(sim$args$data) by_samp$parameters$eps_source table(cluster = by_samp$assignments$cluster, group = sim$args$group)## Two well-separated blobs and a couple of strays. The count is not an ## argument, so finding two is the result rather than the setup. set.seed(1) blobs <- rbind( matrix(rnorm(60, mean = -3), ncol = 2), matrix(rnorm(60, mean = 3), ncol = 2), cbind(c(-12, 12), c(12, -12)) ) colnames(blobs) <- c("x", "y") res <- cluster_dbscan(blobs, scale = FALSE) res table(res$assignments$cluster) plot(blobs, col = res$assignments$cluster + 1L, pch = 16, main = "cluster 0, in black, is noise") ## On a planted two-group structure, with the radius left to be derived. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_dbscan(sim$args$data) by_samp$parameters$eps_source table(cluster = by_samp$assignments$cluster, group = sim$args$group)
Merges the two closest points, then the two closest groups, and keeps going
until everything is one group; then cuts the resulting tree so that n_clust
groups fall out. What comes back is one cluster label per point, beside the
stats::hclust() tree the labels were cut from.
cluster_hclust( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, n_clust = 2, dist_method = c("euclidean", "manhattan", "correlation"), hclust_method = c("average", "complete", "ward.D2") )cluster_hclust( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, n_clust = 2, dist_method = c("euclidean", "manhattan", "correlation"), hclust_method = c("average", "complete", "ward.D2") )
data |
A data.frame or a matrix in wide format, one row per sample and one
column per feature. This is the same layout |
feats |
Column names to cluster on, or |
cluster_scale |
Which margin becomes the points being clustered:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation before measuring anything. Scaling is on by default because features
are not measured on a common scale, and without it the feature with the widest
units decides which points are close. Both always apply to the columns of
|
n_clust |
How many groups to cut the tree into. The tree itself does not
depend on this, so |
dist_method |
What "close" means. |
hclust_method |
Linkage handed to |
The input is the wide format the comparison functions take: one row per sample
and one column per feature. Which margin of it becomes the thing being
clustered is cluster_scale, and design$point_type reports the answer.
An object of class sa_cluster, a plain list.
analysis"hclust".
pointsLabels of the things that were clustered — samples or
features, as cluster_scale asked — in the row order assignments
follows.
designWhat was clustered: point_type, the feats kept and any
dropped_feats, the counts n_samples, n_used, n_dropped and
n_feats, and the two counts of the answer, n_clusters and n_noise.
n_noise is always 0 here: a tree places every point.
parametersThe choices as they were used.
assignmentsOne row per point: points, cluster and
silhouette.
clustersOne row per cluster: cluster, size and the mean
silhouette of its members.
engineWhat computed the clustering.
fitThe stats::hclust() object. This is the slot that is not
portable; dropping it leaves an object that writes out as JSON.
metadataPackage version, R version, platform and timestamp.
cluster_scale = "samples", the default, puts one point per row of data, and
the question is which samples resemble each other. "features" puts one point
per column, and the question is which features move together. The second is the
one this and draw_heatmap() agree on: the features are standardised first and
the transpose is clustered as it stands, which is also what
perform_pca(embedding_scale = "features") reports on.
Transposing data by hand instead is a third analysis. scale() standardises
columns, so on the transpose it standardises samples, and the picture looks
right while answering a different question. cluster_scale exists so that the
transpose never has to be taken by hand.
dist_method decides what "close" means and hclust_method decides what
"close" means for two groups rather than two points. "correlation" is
1 - cor(), so two points a constant apart are at distance zero: use it when
the shape of a profile is the question and its level is not. The default
"average" linkage is the one that neither chains clusters into strings the way
single linkage does nor insists they come out round the way "ward.D2" does.
The tree is built on exactly the distance draw_heatmap() builds its
dendrogram from, so a heatmap drawn with the same dist_method and
hclust_method is showing this clustering and not a near relative of it.
Rows that are not complete and finite across feats go before the distance is
measured, and design$n_dropped reports how many. This is the listwise deletion
the rest of the package uses; nothing is imputed. A feature that takes a single
value cannot be scaled, so with scale = TRUE it is left out with a message and
named in design$dropped_feats.
cluster_kmeans(), which is told the same n_clust and answers
without a tree, and cluster_dbscan(), which is not told it at all.
draw_heatmap() draws the tree this cuts.
## Three species, and the clustering was not told there were three. res <- cluster_hclust(iris[1:4], n_clust = 3) res table(cluster = res$assignments$cluster, species = iris$Species) ## The tree is `$fit`, so another cut costs nothing. table(stats::cutree(res$fit, k = 2)) ## On a planted two-group structure, clustering the samples. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_hclust(sim$args$data, n_clust = 2) table(cluster = by_samp$assignments$cluster, group = sim$args$group) ## `cluster_scale = "features"` asks the other question: which features move ## together. The correlation distance is the one that ignores their levels. by_feat <- cluster_hclust(sim$args$data, cluster_scale = "features", n_clust = 3, dist_method = "correlation") by_feat split(by_feat$points, by_feat$assignments$cluster)## Three species, and the clustering was not told there were three. res <- cluster_hclust(iris[1:4], n_clust = 3) res table(cluster = res$assignments$cluster, species = iris$Species) ## The tree is `$fit`, so another cut costs nothing. table(stats::cutree(res$fit, k = 2)) ## On a planted two-group structure, clustering the samples. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_hclust(sim$args$data, n_clust = 2) table(cluster = by_samp$assignments$cluster, group = sim$args$group) ## `cluster_scale = "features"` asks the other question: which features move ## together. The correlation distance is the one that ignores their levels. by_feat <- cluster_hclust(sim$args$data, cluster_scale = "features", n_clust = 3, dist_method = "correlation") by_feat split(by_feat$points, by_feat$assignments$cluster)
Places n_clust centres, gives every point to its nearest one, moves each
centre to the mean of the points it was given, and repeats until nothing moves.
What comes back is one cluster label per point, beside the stats::kmeans()
object holding the centres.
cluster_kmeans( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, n_clust = 2, n_start = 25, iter_max = 100, seed = NULL )cluster_kmeans( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, n_clust = 2, n_start = 25, iter_max = 100, seed = NULL )
data |
A data.frame or a matrix in wide format, one row per sample and one column per feature. |
feats |
Column names to cluster on, or |
cluster_scale |
Which margin becomes the points being clustered:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation first. Both always apply to the columns of |
n_clust |
How many centres to place. Unlike the two density methods, this is the number of clusters that will come back. |
n_start |
How many random starts to try, keeping the best. The default of
25 overrides |
iter_max |
Most iterations one start may take before it is abandoned. |
seed |
Seed for the starts, or |
The input is the wide format the comparison functions take: one row per sample
and one column per feature. Which margin of it becomes the thing being
clustered is cluster_scale, and design$point_type reports the answer.
An object of class sa_cluster, a plain list. The slots are
cluster_hclust()'s, with analysis "kmeans", fit the
stats::kmeans() object and design$n_noise always 0, since every point is
given to a centre.
The starting centres are random, so two calls can disagree. Two defences are on
by default and neither replaces the other. n_start = 25 runs the whole thing
twenty-five times from different starts and keeps the one with the smallest
within-cluster sum of squares, which makes a bad local optimum unlikely rather
than impossible. seed makes the run exactly repeatable, and it restores the
random stream afterwards, so seeding this call does not quietly reseed whatever
the caller does next.
Neither is a guarantee that n_clust is the right number. k-means always
returns the number of groups it was asked for, and clusters$silhouette is
where to look for whether they are groups: a cluster near zero is one whose
members are about as close to another cluster as to their own.
cluster_scale = "samples", the default, puts one point per row of data, and
"features" puts one point per column. See cluster_hclust(), which documents
the same argument and why transposing data by hand is a different analysis.
Rows that are not complete and finite across feats go before the centres are
placed, and design$n_dropped reports how many. A feature that takes a single
value cannot be scaled, so with scale = TRUE it is left out with a message and
named in design$dropped_feats.
cluster_hclust(), which is told the same n_clust and returns the
tree it cut, and cluster_dbscan(), which derives the count from the density
instead and may refuse to place a point.
## Three species, and the clustering was not told there were three. res <- cluster_kmeans(iris[1:4], n_clust = 3, seed = 1) res table(cluster = res$assignments$cluster, species = iris$Species) ## The centres are on `$fit`, in the scaled units the clustering ran in. res$fit$centers ## On a planted two-group structure. The group was never shown to the engine. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_kmeans(sim$args$data, n_clust = 2, seed = 1) table(cluster = by_samp$assignments$cluster, group = sim$args$group) ## Asking for more groups than there are shows up in the silhouettes rather ## than in an error: k-means returns whatever number it was asked for. cluster_kmeans(sim$args$data, n_clust = 5, seed = 1)$clusters## Three species, and the clustering was not told there were three. res <- cluster_kmeans(iris[1:4], n_clust = 3, seed = 1) res table(cluster = res$assignments$cluster, species = iris$Species) ## The centres are on `$fit`, in the scaled units the clustering ran in. res$fit$centers ## On a planted two-group structure. The group was never shown to the engine. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_kmeans(sim$args$data, n_clust = 2, seed = 1) table(cluster = by_samp$assignments$cluster, group = sim$args$group) ## Asking for more groups than there are shows up in the silhouettes rather ## than in an error: k-means returns whatever number it was asked for. cluster_kmeans(sim$args$data, n_clust = 5, seed = 1)$clusters
Builds a graph in which every point keeps its k nearest neighbours, links two
points that share at least eps of them, and grows a cluster out of every point
with at least min_pts such links. Points that never joined one are left as
noise. How many clusters there are is the answer rather than the question.
cluster_snn( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, k = NULL, eps = NULL, min_pts = NULL )cluster_snn( data, feats = NULL, cluster_scale = c("samples", "features"), center = TRUE, scale = TRUE, k = NULL, eps = NULL, min_pts = NULL )
data |
A data.frame or a matrix in wide format, one row per sample and one column per feature. |
feats |
Column names to cluster on, or |
cluster_scale |
Which margin becomes the points being clustered:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation first. Both always apply to the columns of |
k |
How many nearest neighbours each point keeps. |
eps |
How many neighbours two points must share before they are linked,
a whole number from 1 to |
min_pts |
How many links a point needs in that graph before it can be the
core of a cluster. |
The input is the wide format the comparison functions take: one row per sample
and one column per feature. Which margin of it becomes the thing being
clustered is cluster_scale, and design$point_type reports the answer.
An object of class sa_cluster, a plain list. The slots are
cluster_hclust()'s, with analysis "snn", fit the
dbscan::sNNclust() object, and design$n_clusters and design$n_noise both
answers rather than arguments.
eps here counts neighbours, and in cluster_dbscan() it is a distanceThe two functions have an argument of the same name meaning two different
things, which is the algorithms' doing rather than this package's:
dbscan::dbscan() and dbscan::sNNclust() both call their threshold eps, and
renaming one of them here would make this package's documentation disagree with
the engine's. In cluster_dbscan() eps is a radius in the units of the data
and any positive number is meaningful. Here it is a count of shared neighbours,
so it is a whole number between 1 and k, and a value of k / 2 says "half of
what each of you considers close is the same points".
One radius has to be right everywhere, so DBSCAN cannot find a tight cluster and
a loose one in the same call: the eps that reaches across the loose one merges
the tight one into its surroundings. Shared neighbours are relative, so a point
in a sparse region is still close to its own neighbours, and clusters of
different densities come out together.
The price is dimension. As the number of variables grows, distances concentrate
and neighbour lists start to overlap for no reason, so this method will report
structure that is an artefact of the width of the table. clusters$silhouette
is the check worth making, and agreement with cluster_dbscan() on the same
points is worth more than either alone.
A point that never joined a dense part of the graph gets cluster 0, has no
silhouette and no row in clusters; design$n_noise is the count. Every point
being noise is a possible answer and means the overlap asked for is not there,
which is usually eps or min_pts being too large for the k in use.
cluster_dbscan(), the other density method here, which measures
closeness with one radius rather than by shared neighbours.
## Two blobs of different spread, which is the case a single radius handles ## badly and shared neighbours handle well. set.seed(1) blobs <- rbind( matrix(rnorm(80, mean = -6, sd = 0.4), ncol = 2), matrix(rnorm(80, mean = 6, sd = 2.5), ncol = 2) ) colnames(blobs) <- c("x", "y") res <- cluster_snn(blobs, scale = FALSE, k = 10) res table(res$assignments$cluster) plot(blobs, col = res$assignments$cluster + 1L, pch = 16, main = "cluster 0, in black, is noise") ## On a planted two-group structure, with everything left to be derived. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_snn(sim$args$data) table(cluster = by_samp$assignments$cluster, group = sim$args$group)## Two blobs of different spread, which is the case a single radius handles ## badly and shared neighbours handle well. set.seed(1) blobs <- rbind( matrix(rnorm(80, mean = -6, sd = 0.4), ncol = 2), matrix(rnorm(80, mean = 6, sd = 2.5), ncol = 2) ) colnames(blobs) <- c("x", "y") res <- cluster_snn(blobs, scale = FALSE, k = 10) res table(res$assignments$cluster) plot(blobs, col = res$assignments$cluster + 1L, pch = 16, main = "cluster 0, in black, is noise") ## On a planted two-group structure, with everything left to be derived. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- cluster_snn(sim$args$data) table(cluster = by_samp$assignments$cluster, group = sim$args$group)
The fit element of an sa_model is the engine object, kept so that the
model can predict(). These methods make the rest of what anyone does with a
fitted model work on it as well: coef() and summary() read through to the
stats::lm() or stats::glm() fit that caret::train() built, so what comes
back is what the same call on that model would give.
## S3 method for class 'sa_fit' coef(object, ...) ## S3 method for class 'sa_fit' summary(object, ...) ## S3 method for class 'sa_fit' predict(object, newdata = NULL, type = "raw", ...)## S3 method for class 'sa_fit' coef(object, ...) ## S3 method for class 'sa_fit' summary(object, ...) ## S3 method for class 'sa_fit' predict(object, newdata = NULL, type = "raw", ...)
object |
The |
... |
Passed on to the method of the underlying fit. |
newdata |
Rows to predict as |
type |
|
The numbers are also in $coefficients, as a data.frame with the confidence
interval beside them and, for a logistic regression, the odds ratio, and that
table is what coef.sa_model() gives for the result as a whole. These methods
are for when an lm-shaped answer is what is wanted instead: a named vector to
index, or the summary layout that is already familiar.
On a fit_elastic_net() fit, coef() is the coefficients at the penalty that
was chosen, which is the model the result describes and the one predict()
predicts from, rather than the whole lambda path glmnet keeps. summary()
has nothing of the same shape to return there, since a penalized fit has no
standard errors and no residual degrees of freedom to test against; what it
does have is in $coefficients and $fit_stats.
A fit_rf() or fit_svm() fit has neither. coef() on one is an error naming
the importance table to read instead, rather than the NULL coef() on a
randomForest object gives or the S4 indexing error it gives on a ksvm one,
and summary() reaches a model with no summary method of its own.
print(fit$fit) is the readable answer for both, and everything the result
reports is in $coefficients and $fit_stats.
predict() here takes newdata as caret prepared it rather than as the fit
received it, which for fit_elastic_net() and fit_svm() is a design matrix
and not a data frame of predictors. predict.sa_model() is the method that
takes the rows
themselves, since the result is what knows which columns they were and what
levels its factors had.
predict() is otherwise caret's, with one word added. caret::train() knows
type = "raw" for the prediction itself and type = "prob" for the class
probabilities, and refuses anything else; "response", which is what
stats::glm() and stats::lm() call the prediction on the scale of the
outcome, is therefore an error on an object that is plainly a logistic
regression. It is accepted here and means the same thing in both models: the
predicted value for a linear regression, and for a logistic one the
probability of outcome_lv[2], the class every coefficient and odds ratio in
the result already describes. The probability is caret's own, the second
column of type = "prob", so nothing is computed twice.
From coef(), a named numeric vector of coefficients, NA for a term
the fit could not estimate and exactly 0 for one a penalty dropped. From
summary(), the summary.lm or
summary.glm object of the underlying fit. The Call it prints belongs to
caret rather than to you: .outcome ~ . on the predictor frame for a
linear model, and empty for a logistic one, which caret fits without a
formula. What was fitted to what is what print() on the result says.
From predict(), whatever caret::predict.train() returns for that type,
except that type = "response" on a classification gives an unnamed numeric
vector of probabilities, one per row of newdata.
fit <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) coef(fit$fit) summary(fit$fit) ## The same numbers the result reports in its own table. fit$coefficients ## On a classification, `type = "response"` is the probability of the second ## level of `outcome_lv`, the one the coefficients describe. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_logistic_regression(iris2, outcome = "Species", predictors = "Petal.Length", outcome_lv = c("versicolor", "virginica"), cv = FALSE) head(predict(clf$fit, newdata = iris2, type = "response")) ## A penalized fit answers with the coefficients at the chosen penalty, the ## ones `$coefficients` reports, rather than with the whole lambda path. pen <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 0.5, cv = FALSE) coef(pen$fit)fit <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) coef(fit$fit) summary(fit$fit) ## The same numbers the result reports in its own table. fit$coefficients ## On a classification, `type = "response"` is the probability of the second ## level of `outcome_lv`, the one the coefficients describe. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_logistic_regression(iris2, outcome = "Species", predictors = "Petal.Length", outcome_lv = c("versicolor", "virginica"), cv = FALSE) head(predict(clf$fit, newdata = iris2, type = "response")) ## A penalized fit answers with the coefficients at the chosen penalty, the ## ones `$coefficients` reports, rather than with the whole lambda path. pen <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 0.5, cv = FALSE) coef(pen$fit)
The coefficient table, object$coefficients itself: one row per term, in the
order of object$terms, carrying everything the model estimated about each of
them rather than the estimate alone.
## S3 method for class 'sa_model' coef(object, ...)## S3 method for class 'sa_model' coef(object, ...)
object |
A fitted model, as returned by |
... |
Ignored, present for consistency with |
The lm-shaped answer, a named numeric vector, is coef(x$fit). Two objects
are reached by two calls, so they answer in their own terms rather than both
giving the same thing: the engine object answers as the engine does, and the
result object answers with the table it was assembled to hold. Ask $fit when
a vector is what is wanted to index or multiply.
What the table holds depends on the model, and the columns say which kind it
is. An unpenalized fit carries the standard error, the statistic, the p-value
and the confidence limits, and a classification the odds ratio; a penalized one
has no standard error to report and carries selected instead, so
is.null(coef(x)$pval) is the test for a fit that cannot be asked for
inference. A forest and a support vector machine have no coefficient of any
kind, and their table is the importance of each term rather than an effect per
unit of it; see fit_rf() and fit_svm(). Every term keeps its row either way — a term a penalty dropped with
an estimate of exactly 0, one the engine could not estimate with NA — since
a table shorter than the model would be a table that had lost terms rather than
one describing terms that went to zero.
The coefficients element of object, a data.frame of one row per
term.
fit_linear_regression(), fit_elastic_net(), fit_rf(),
fit_svm(), and coef.sa_fit() for the same question asked of the engine
object in $fit.
fit <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) coef(fit) ## The named vector is one object further in. coef(fit$fit) ## A penalized fit answers with what it has: no inference, but `selected`. pen <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 0.5, cv = FALSE) coef(pen)fit <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) coef(fit) ## The named vector is one object further in. coef(fit$fit) ## A penalized fit answers with what it has: no inference, but `selected`. pen <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 0.5, cv = FALSE) coef(pen)
Crosses two categorical variables into a contingency table and returns an asymptotic and an exact test of it side by side, together with the measures of how strong the association is. Nothing is chosen on the user's behalf: reporting both makes disagreement between them visible, which is the situation where the choice between an approximation and an exact enumeration actually matters.
compare_categorical_groups( data, category_lv = NULL, control_label = NULL, paired = FALSE, conf_level = 0.95, correct = TRUE, exact = NULL, simulate_p_value = FALSE, n_resamples = 9999, max_levels = 20L, seed = NULL, diagnose = TRUE )compare_categorical_groups( data, category_lv = NULL, control_label = NULL, paired = FALSE, conf_level = 0.95, correct = TRUE, exact = NULL, simulate_p_value = FALSE, n_resamples = 9999, max_levels = 20L, seed = NULL, diagnose = TRUE )
data |
A data.frame (or matrix) whose columns are the categorical
variables. Unlike the other comparison scenarios there is no |
category_lv |
Named list giving the levels of each variable, with the
reference level first, or |
control_label |
The level to hold as the reference. For an independent
design this is one name per variable it points at, as a named list
( |
paired |
Logical. If |
conf_level |
Confidence level for the association intervals and for the conditional odds ratio of Fisher's exact test. |
correct |
Whether to apply the continuity correction to the chi-square
approximation. |
exact |
Read only by McNemar's test. |
simulate_p_value |
Read only by the tests of an independent design. Replaces the chi-square approximation with a Monte Carlo p-value, and is the way to get an answer out of Fisher's test on a large r x c table that cannot be enumerated. |
n_resamples |
How many tables the Monte Carlo p-value is taken over. |
max_levels |
How many levels a variable may take before it is refused as a
category. |
seed |
Seed for the Monte Carlo p-value, restored on exit, so a simulated p-value is reproducible without the caller's random stream being disturbed. |
diagnose |
Logical. If |
This is the one scenario in the package with no feature axis. Every other
comparison asks its question once per numeric column and returns a table with
one row per column; a contingency table is asked about as a whole, so the
result carries the table itself in $cells and one row per test in $tests.
That is also why the result is not an sa_comparison and does not go through
estimate_significance(): a volcano plot needs a signed effect per feature and
this scenario has no feature axis to carry one.
estimate_categorical_significance() is the counterpart that reads it, one
verdict per cell. See "Why this is not a comparison result".
A row is dropped for one of two reasons, and the two are counted apart. A row
naming a level category_lv leaves out was measured and excluded, and appears
as design$n_dropped. A row missing a value in any variable was not measured,
and appears as design$n_incomplete; a table needs the whole row, so the
deletion is listwise.
The warning stats::chisq.test() raises about small expected counts is not
passed on. $diagnostics states the same fact as a number, and the exact test
that does not need the approximation is already in the same result, so a
warning would be a less precise version of what is sitting next to it.
Fisher's exact test enumerates every table with the observed margins, and on a
large r x c one there are more of those than the algorithm's workspace holds.
That is a limit of the enumeration rather than a fault in the data, so
$tests$fisher_test$pval comes back NA with a message saying so, instead of
the whole call failing and taking the chi-square result with it.
simulate_p_value = TRUE is the way to get an answer there.
An sa_categorical object: a plain list, so it survives being written
out as JSON and read back in another language, with an S3 class on top that
supplies print(), plot() and as.table(). Its elements are
analysis"categorical_comparison".
variablesThe variable names, in the order category_lv fixed.
designcategory_lv, null, paired, pairing, dim,
row_var, col_var, n_used, n_dropped and n_incomplete. null is
"independence", "symmetry" or "marginal_homogeneity", and it is what
expected and the residuals are read under.
parametersThe analysis choices as used, so exact says which
branch of McNemar's test ran rather than what was passed.
cellsOne row per cell of the table: row_level, col_level,
observed, expected, residual, std_residual, prop_total,
prop_row and prop_col. This is the canonical form of the table, and
what draw_mosaic_plot() reads. as.table() folds it back into a
table(), which is the shape to read it in rather than a second copy to
keep.
testsOne one-row data.frame per test, named chisq_test,
fisher_test, mcnemar_test or cochran_q, each carrying n_used,
statistic, df, pval, lower_conf and upper_conf. There is no
pval_adj.
test_infoThe method id, a readable label and whether the test
is a matched one, per element of tests.
associationOne row per measure: measure, estimate,
lower_conf, upper_conf. Which measures are defined depends on the
design and on the size of the table.
diagnosticsThe approximation rule this design rests on, or
NULL.
metadatapackage_version, r_version, platform and an
ISO-8601 timestamp.
The chi-square statistic answers what look like three different questions, and the arithmetic is the same in each.
Both variables were measured on one sample: is there any association between them? This is the reading the default label uses.
One variable says which sample a row came from and the other is what was measured: do the samples share the same distribution over the categories? The sampling scheme differs, the null hypothesis is stated differently, and the test is the same one.
One variable against a set of expected proportions. This one is genuinely different, being about a single margin rather than a cross-classification, and it is not implemented here.
Which of the first two a caller means is a fact about their sampling scheme, not about their data, and the data cannot be inspected to find out. So this function does not ask, and both readings are the same call.
Decided by paired and by how many variables category_lv names, so there is
no argument naming a test.
| design | null hypothesis | tests |
| independent, two variables | independence | Chi-square test of independence, Fisher's exact test |
| matched, two binary conditions | symmetry | McNemar's test |
| matched, three or more binary conditions | marginal homogeneity | Cochran's Q test |
A matched design reads the columns as repeated measurements of one thing on the
same row, so pairing is by row and there is no id argument. It also requires
the levels to be binary: the tests of symmetry that generalise McNemar's test
past two levels, Bowker's and Stuart-Maxwell's, are not implemented, and a
three-level matched design is an error naming them rather than a table quietly
collapsed to two levels.
design$null names the hypothesis the whole result is about, and
$cells$expected is read under it. This matters because a contingency table
can be held against more than one null and the three designs above hold it
against three different ones.
Under independence a cell is expected at the product of its margins over the total. Under symmetry it is expected at the average of it and its transpose, so the diagonal is expected at exactly what it holds and carries no residual: a pair that answered the same way under both conditions says nothing about which condition raises the response. Under marginal homogeneity every condition is expected to show the pooled response rate, which on a condition-by-response table is the same arithmetic as independence and a different claim about the world.
The residuals follow the same hypothesis, which is what keeps them and the
p-value beside them talking about one thing. In a matched 2 x 2 the squared
Pearson residuals of the two discordant cells sum to (b - c)^2 / (b + c),
which is McNemar's uncorrected statistic exactly; the cell table and the test
are the same arithmetic read two ways. draw_mosaic_plot() shades on those
residuals, so the picture is about the hypothesis the result tested rather than
about independence in every case.
std_residual is the exception. Its variance correction is derived for a table
held against its own margins and has no counterpart under symmetry, so it is
NA there rather than a number that looks referable to a standard normal and
is not.
sa_new_comparison() holds every table in a comparison to one row per feature,
and estimate_significance() reads one log2fc per row off that axis. Neither
exists here. There is one table, so there is one p-value and no multiplicity to
adjust across, which is why no pval_adj column is carried. And an association
has no sign: cramers_v says how far the table sits from independence but not
in which direction, because past a 2 x 2 table there is no single direction to
name. On a 2 x 2 table there is one, and odds_ratio reports it.
The result is therefore an sa_categorical, which keeps the vocabulary of a
comparison – design, parameters, tests beside test_info, metadata –
without claiming a contract it cannot meet. draw_mosaic_plot() is what reads
it, in the place draw_volcano_plot() holds for the numeric scenarios.
Every sentence above is about the table. A cell does have both axes:
observed / expected is a signed departure defined on a table of any shape,
and std_residual beside it is referred to a standard normal, so the cells of
one table are a family to adjust across.
estimate_categorical_significance() is what reads that axis, and it is a
function of $cells alone, so no slot is carried for it.
Set once, by the order of the levels in category_lv, and control_label is
the second way of stating it. Every quantity that has a direction follows it.
odds_ratio is above 1 when the second level of each variable occurs with
the second level of the other more often than independence would give, and
phi_coefficient is above zero in the same situation. Pointing
control_label at the other level of either variable inverts both, and
pointing it at the other level of both leaves them where they were. In a
matched design the second level is the response the conditions are compared on,
so odds_ratio_paired is above 1 when the later condition raises it.
An independent table always reports cramers_v and
contingency_coefficient, and a 2 x 2 one adds phi_coefficient and
odds_ratio. A matched 2 x 2 reports odds_ratio_paired,
risk_difference_paired and cohens_g, all three read off the discordant
cells. Three or more matched conditions report kendalls_w.
Only odds_ratio and the paired measures carry an interval. The others are
functions of the chi-square statistic whose sampling distribution has no
closed-form interval, so their lower_conf and upper_conf are NA: the
columns exist because every result table in the package carries them, not
because every number in them is finite.
Every measure is built from the uncorrected chi-square statistic, whatever
correct was set to. Yates' correction is about referring a discrete statistic
to a continuous distribution, which is a statement about a p-value; letting it
into an effect size would make the reported strength of an association depend
on a choice made about its tail probability.
Pearson, K. (1900). On the criterion that a given system of deviations from the probable in the case of a correlated system of variables is such that it can be reasonably supposed to have arisen from random sampling. Philosophical Magazine, 50(302), 157-175.
Fisher, R. A. (1935). The logic of inductive inference. Journal of the Royal Statistical Society, 98(1), 39-82.
McNemar, Q. (1947). Note on the sampling error of the difference between correlated proportions or percentages. Psychometrika, 12(2), 153-157.
Cochran, W. G. (1950). The comparison of percentages in matched samples. Biometrika, 37(3-4), 256-266.
Agresti, A. (2002). Categorical Data Analysis, 2nd ed. Wiley.
simulate_categorical_groups() for a table whose association is
known, estimate_categorical_significance() to reduce the result to a
verdict per cell, draw_mosaic_plot() to draw it, and
compare_multiple_groups() for the case where the measurement is numeric and
only the grouping is categorical.
## Both columns are categorical, so `data` is the whole input. smoking <- data.frame( smoker = rep(c("y", "n"), each = 60), grade = c(rep(c("high", "mid", "low"), c(10, 20, 30)), rep(c("high", "mid", "low"), c(30, 20, 10))) ) res <- compare_categorical_groups(smoking) res ## The table itself, and the cells that made the statistic what it is. as.table(res) res$cells[c("row_level", "col_level", "observed", "expected", "std_residual")] ## How strong the association is, which no test reports. res$association ## `category_lv` picks the levels and their order, and drops the rest. Two ## levels of `grade` make a 2 x 2 table, which is where the odds ratio exists. two_by_two <- compare_categorical_groups( smoking, category_lv = list(smoker = c("n", "y"), grade = c("low", "high")) ) subset(two_by_two$association, measure == "odds_ratio") ## `control_label` restates the reference without the levels being retyped, ## and pointing it at one variable inverts the odds ratio. flipped <- compare_categorical_groups( smoking, category_lv = list(smoker = c("n", "y"), grade = c("low", "high")), control_label = c(smoker = "y") ) subset(flipped$association, measure == "odds_ratio") ## A matched design: the columns are the same question asked twice, so pairing ## is by row, the null is symmetry rather than independence, and McNemar's test ## reads only the discordant pairs. before_after <- data.frame( before = rep(c("pass", "fail"), c(20, 30)), after = c(rep(c("pass", "fail"), c(18, 2)), rep(c("pass", "fail"), c(14, 16))) ) matched <- compare_categorical_groups(before_after, paired = TRUE) matched$design$null ## Which is what the cell table is read under: the diagonal is expected at ## exactly what it holds, so only the discordant cells carry a residual. matched$cells[c("row_level", "col_level", "observed", "expected", "residual")] ## A simulated table hands over exactly the arguments this function takes. sim <- simulate_categorical_groups(n_samples = 400, assoc = 0.4, seed = 1) fit <- do.call(compare_categorical_groups, sim$args) cbind(planted = sim$truth$cramers_v, estimated = fit$association$estimate[1])## Both columns are categorical, so `data` is the whole input. smoking <- data.frame( smoker = rep(c("y", "n"), each = 60), grade = c(rep(c("high", "mid", "low"), c(10, 20, 30)), rep(c("high", "mid", "low"), c(30, 20, 10))) ) res <- compare_categorical_groups(smoking) res ## The table itself, and the cells that made the statistic what it is. as.table(res) res$cells[c("row_level", "col_level", "observed", "expected", "std_residual")] ## How strong the association is, which no test reports. res$association ## `category_lv` picks the levels and their order, and drops the rest. Two ## levels of `grade` make a 2 x 2 table, which is where the odds ratio exists. two_by_two <- compare_categorical_groups( smoking, category_lv = list(smoker = c("n", "y"), grade = c("low", "high")) ) subset(two_by_two$association, measure == "odds_ratio") ## `control_label` restates the reference without the levels being retyped, ## and pointing it at one variable inverts the odds ratio. flipped <- compare_categorical_groups( smoking, category_lv = list(smoker = c("n", "y"), grade = c("low", "high")), control_label = c(smoker = "y") ) subset(flipped$association, measure == "odds_ratio") ## A matched design: the columns are the same question asked twice, so pairing ## is by row, the null is symmetry rather than independence, and McNemar's test ## reads only the discordant pairs. before_after <- data.frame( before = rep(c("pass", "fail"), c(20, 30)), after = c(rep(c("pass", "fail"), c(18, 2)), rep(c("pass", "fail"), c(14, 16))) ) matched <- compare_categorical_groups(before_after, paired = TRUE) matched$design$null ## Which is what the cell table is read under: the diagonal is expected at ## exactly what it holds, so only the discordant cells carry a residual. matched$cells[c("row_level", "col_level", "observed", "expected", "residual")] ## A simulated table hands over exactly the arguments this function takes. sim <- simulate_categorical_groups(n_samples = 400, assoc = 0.4, seed = 1) fit <- do.call(compare_categorical_groups, sim$args) cbind(planted = sim$truth$cramers_v, estimated = fit$association$estimate[1])
The factorial counterpart of compare_multiple_groups(), and the one place in
the package where a scenario function fits one model rather than running
every applicable test side by side. Two crossed factors are a two-way ANOVA,
three are a three-way ANOVA and more than three are a factorial ANOVA, and
those are three names for the same fully crossed linear model rather than three
procedures to choose between. Which name applies follows from
length(factor_lv), so there is no argument for it; the answer is reported in
design$anova_type and in the readable label of the test.
compare_factorial_groups( data, feats, factors, factor_lv = NULL, control_label = NULL, within = NULL, id = NULL, conf_level = 0.95, ss_type = c("III", "II", "I"), posthoc = TRUE, posthoc_alpha = 0.05, posthoc_scope = c("both", "marginal", "simple"), fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", diagnose = TRUE )compare_factorial_groups( data, feats, factors, factor_lv = NULL, control_label = NULL, within = NULL, id = NULL, conf_level = 0.95, ss_type = c("III", "II", "I"), posthoc = TRUE, posthoc_alpha = 0.05, posthoc_scope = c("both", "marginal", "simple"), fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", diagnose = TRUE )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
factors |
Named list of the crossed factors, each entry either the name of
a column of |
factor_lv |
Named list giving the levels of each factor, with the
reference level first, or |
control_label |
The level to hold as the reference, one name per factor
it points at, as a named list ( |
within |
Names of the factors measured within subjects. Not implemented
in this version: a non-empty value is an error rather than a design that is
silently analysed as though the repeated measurements were independent. The
argument exists so that the error names what is missing, which an
|
id |
Subject identifier with one entry per row of |
conf_level |
Confidence level for the post-hoc intervals. |
ss_type |
Which sums of squares the term tests are built from, |
posthoc |
Logical. If |
posthoc_alpha |
A contrast is computed when the term it belongs to has a
|
posthoc_scope |
Which contrasts the pairwise stage covers: |
fc_mean |
Which centre the fold change divides, |
input_scale |
The scale |
p_adjust |
Multiplicity adjustment passed to |
diagnose |
Logical. If |
What multiplies instead is the number of answers. One factor asks whether its
levels are alike; crossing a second one asks that of each factor and asks
whether either one's effect depends on the other, and those questions fail
separately. A single p-value cannot carry them, so the result has an axis the
sibling functions do not: $terms, one row per feature and model term.
Features that cannot be tested do not abort the run. Their rows are filled with
NA and all such features are reported together in a single warning.
A sa_comparison object with the layout compare_multiple_groups()
returns, minus $pairwise and plus $terms and $cells. Its elements are
analysis"factorial_comparison".
featuresThe feature names, in the row order every table uses.
designfactor_lv, anova_type, n_factors, group_lv (the
cell labels), cell_n, n_empty_cells, paired, pairing, within,
n_dropped and unmatched_ids.
parametersThe analysis choices as used, plus n_posthoc, the
number of features that entered the pairwise stage.
effectOne row per feature: n_used, n_cells, ref_center,
extreme_cell, extreme_center, fold_change and log2fc. The
reference cell is the one where every factor sits at its first level,
which factor_lv or control_label states, and
extreme_cell is whichever cell sits furthest from it on the log2 scale,
the same definition simulate_factorial_groups() records in
truth$extreme_cell.
testsOne table, anova_test, holding the whole-model F test with
one row per feature.
termsOne row per feature and model term, holding features,
terms, term_order, n_used, df, ss, ms, f_stat, df_error,
eta_sq, partial_eta_sq, log2_effect, pval and pval_adj.
cellsOne row per feature and cell of the grid, holding
features, one column per factor named after it and holding the level
name, cell (the dot-joined label, as in design$group_lv), n,
mean, sd and se. mean is the arithmetic cell mean the model was
fitted on and se is sqrt(ms_error / n), pooled over the whole model,
so the variance of a marginal mean over a set of cells is recoverable
from it. This is the table draw_interaction_plot() reads, and the one
part of the grid the rest of the result cannot be read backwards into.
posthocanova_test, one row per feature and contrast, carrying
the post-hoc contract columns plus factor and stratum. Absent when
posthoc = FALSE.
test_infoThe method id, a readable label naming the ANOVA
that ran, and the post-hoc procedure that followed it.
diagnosticsAssumption checks over the cells, or NULL.
metadatapackage_version, r_version, platform and an
ISO-8601 timestamp.
The whole-model test in $tests$anova_test asks whether a feature responds to
the design at all. That is one question per feature, and it is exactly the
one-way ANOVA that treats the cells as groups: a fully crossed model is the
cell means model written in another basis, so the two fit the same values and
leave the same residuals. Keeping it there is what lets
estimate_significance(), print(), draw_forest_plot() and
draw_volcano_plot() read a factorial result without knowing that it is one.
Which part of the design a feature responds to is the question a crossed
model was fitted to answer, and it has one answer per term. Those live in
$terms, whose terms and term_order columns are the ones
simulate_factorial_groups() writes into truth_term, so a result and an
answer key merge on c("features", "terms") with neither side renamed.
pval_adj is adjusted within each term, across features. A term is one family:
asking of five hundred features whether the treatment matters is five hundred
instances of one question, while asking whether the treatment matters and
whether it depends on sex are two questions and pooling them would correct each
for the other's multiplicity.
There is no $pairwise slot. Its keys are contrast labels, and a factorial
design has a two-dimensional contrast axis, factor by stratum, that a flat
list keyed by label cannot hold without a naming convention. Until there is
one, estimate_significance(by = "contrast") reports that the result has no
pairwise stage, which is the true statement.
eta_sq and partial_eta_sq say how much of the variance a term accounts for,
and neither has a sign, so neither can be read as a direction. log2_effect
can: it is the largest ANOVA component of the term, with its sign, taken by
decomposing log2() of the same cell centres effect is built from. It is
what estimate_significance() puts on the effect axis in a term reading, and
so what draw_volcano_plot() plots a term panel against when passed the
verdict of estimate_significance(fact, by = "term").
A component is a deviation from what the rest of the model already predicts,
not a difference between two levels. A two-level factor whose levels differ
by one log2 unit has components -0.5 and +0.5, so |log2_effect| is 0.5
where the marginal fold change is 2. Exact and near ties in absolute value
(within the factorial tolerance) keep the earlier, reference cell's sign; do
not read that sign as a treatment-versus-control direction. The definition is
kept because it is the one simulate_factorial_groups() records in
truth_term$max_abs_delta, which makes the column scorable against an answer
key; the consequence is that cutoffs meant for a fold change,
log2fc_cutoff = 1 among them, are stricter here than they look.
Components do not depend on which cell is the reference, since subtracting one
cell from every cell cancels out of the deviation. That is the one place the
term axis is simpler than effect$log2fc, which is read against the reference
cell by definition.
With equal cell sizes the three types of sums of squares are identical and
ss_type changes nothing. They part company when the cells are unequal,
because the factors are then no longer orthogonal and some of the variation
can be attributed to more than one of them.
The default is Type III, each term adjusted for every other term. It is the
type whose main effects are statements about the levels rather than about how
many observations happened to land in each, which is what makes it agree with
the unweighted marginal means the post-hoc stage compares and with the
decomposition simulate_factorial_groups() plants. Type II adjusts a term
for every term that does not contain it, leaving a main effect unadjusted for
the interaction it is part of. Type I is sequential and therefore depends
on the order factors was written in; it is what stats::aov() reports, which
makes it the type to ask for when checking these numbers against an external
implementation on unbalanced data.
A cell holding no usable observation leaves the crossed model with nothing to
estimate there, so that feature's rows are NA and the reason is reported in
one warning. design$n_empty_cells counts the cells that hold no rows at all.
A marginal contrast compares two levels of one factor with the other factors
averaged away, which is the comparison a main effect is a statement about. A
simple effect compares the same two levels inside one combination of the other
factors, and it is the only one of the two that means anything when an
interaction is real: if the treatment helps males and harms females, the
average of the two is a number that describes nobody. stratum tells them
apart, NA for a marginal contrast and the levels of the other factors joined
by a dot for a simple one, and factor names the factor being compared. Both
columns are the ones truth_contrast carries.
The marginal mean is the unweighted mean of the cell means rather than the
mean of the observations, so a level is not pulled towards whichever
combination of the other factors was sampled most heavily. Every contrast is
scaled by the mean square error of the whole model, which is what makes the
pairwise stage consistent with the term tests instead of a second analysis of
the same data, and judged against the studentised range over the number of
levels of its own factor. Those p-values are already family-wise within a
block, so there is no posthoc_p_adjust argument to apply and
parameters$posthoc_p_adjust is NA.
Which contrasts are computed is decided term by term. A marginal contrast of a
factor runs when that factor's main effect cleared posthoc_alpha, and a
simple effect runs when the interaction of the factor with the factors held
fixed cleared it. Gating everything on the whole-model test instead would
compare the levels of a factor that the model says has no effect, on the
strength of a different factor that has one.
Fisher, R. A. (1935). The Design of Experiments.
Yates, F. (1934). The analysis of multiple classifications with unequal numbers in the different classes. Journal of the American Statistical Association, 29(185), 51-66.
Tukey, J. W. (1949). Comparing individual means in the analysis of variance. Biometrics, 5(2), 99-114.
Kramer, C. Y. (1956). Extension of multiple range tests to group means with unequal numbers of replications. Biometrics, 12(3), 307-310.
compare_multiple_groups() for a single factor,
simulate_factorial_groups() for data whose answer per term is known, and
draw_forest_plot() to draw the result.
## Two crossed factors, so a two-way ANOVA. The factors name columns of `data`. res <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension") ) res ## The whole-model test, one row per feature: does this feature respond at all. res$tests$anova_test ## The answer per term, which is what the design was crossed to get. res$terms ## Every fold change is read against the cell where each factor sits at its ## first level. `control_label` moves one of those levels without the rest ## having to be listed, so the reference cell becomes A.M rather than A.L. medium <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension"), control_label = list(tension = "M"), posthoc = FALSE ) medium$design$group_lv[1] medium$effect[c("ref_center", "extreme_cell", "log2fc")] ## The whole-model volcano names the reference cell on the x axis. draw_volcano_plot(estimate_significance(res, log2fc_cutoff = 0.1)) ## Marginal contrasts and simple effects, told apart by `stratum`. subset(res$posthoc$anova_test, factor == "tension" & is.na(stratum)) ## A simulated design hands over exactly the arguments this function takes, ## and `truth_term` is the answer key for `$terms`. sim <- simulate_factorial_groups(n_feats = 12, n_per_cell = 8, seed = 1) fac <- do.call(compare_factorial_groups, sim$args) scored <- merge(fac$terms, sim$truth_term, by = c("features", "terms")) with(scored, table(called = pval_adj <= 0.05, planted = is_effect, terms))## Two crossed factors, so a two-way ANOVA. The factors name columns of `data`. res <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension") ) res ## The whole-model test, one row per feature: does this feature respond at all. res$tests$anova_test ## The answer per term, which is what the design was crossed to get. res$terms ## Every fold change is read against the cell where each factor sits at its ## first level. `control_label` moves one of those levels without the rest ## having to be listed, so the reference cell becomes A.M rather than A.L. medium <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension"), control_label = list(tension = "M"), posthoc = FALSE ) medium$design$group_lv[1] medium$effect[c("ref_center", "extreme_cell", "log2fc")] ## The whole-model volcano names the reference cell on the x axis. draw_volcano_plot(estimate_significance(res, log2fc_cutoff = 0.1)) ## Marginal contrasts and simple effects, told apart by `stratum`. subset(res$posthoc$anova_test, factor == "tension" & is.na(stratum)) ## A simulated design hands over exactly the arguments this function takes, ## and `truth_term` is the answer key for `$terms`. sim <- simulate_factorial_groups(n_feats = 12, n_per_cell = 8, seed = 1) fac <- do.call(compare_factorial_groups, sim$args) scored <- merge(fac$terms, sim$truth_term, by = c("features", "terms")) with(scored, table(called = pval_adj <= 0.05, planted = is_effect, terms))
Compares three or more group levels across any number of numeric features and
returns the omnibus tests side by side, each followed by the post-hoc
procedure that shares its assumptions. As with compare_two_groups(),
nothing is chosen on the user's behalf: reporting the parametric, the
rank-based and the robust result together makes disagreement between them
visible, which is the situation where the choice of test actually matters.
compare_multiple_groups( data, feats, group, group_lv, control_label = group_lv[1], id = NULL, paired = FALSE, conf_level = 0.95, tr = 0.2, posthoc = TRUE, posthoc_alpha = 0.05, fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", posthoc_p_adjust = "holm", diagnose = TRUE )compare_multiple_groups( data, feats, group, group_lv, control_label = group_lv[1], id = NULL, paired = FALSE, conf_level = 0.95, tr = 0.2, posthoc = TRUE, posthoc_alpha = 0.05, fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", posthoc_p_adjust = "holm", diagnose = TRUE )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of at least three group levels. The first is
the reference the |
control_label |
The level to hold as the reference. Naming it moves that
level to the front of |
id |
Subject identifier with one entry per row of |
paired |
Logical. If |
conf_level |
Confidence level for the post-hoc intervals. |
tr |
Trimming proportion for the trimmed mean ANOVA, in |
posthoc |
Logical. If |
posthoc_alpha |
A feature enters the post-hoc stage when its omnibus
|
fc_mean |
Which centre the fold change divides, |
input_scale |
The scale |
p_adjust |
Multiplicity adjustment applied across |
posthoc_p_adjust |
Multiplicity adjustment applied across the contrasts within each feature of a post-hoc table. Ignored for Tukey's HSD and Games-Howell, whose p-values are already family-wise. |
diagnose |
Logical. If |
Which family runs depends on paired:
| slot | paired = FALSE |
paired = TRUE |
anova_test |
One-way ANOVA | Repeated measures ANOVA |
welch_test |
Welch's ANOVA | not applicable |
robust_test |
Yuen's trimmed mean ANOVA | not applicable |
kruskal_test |
Kruskal-Wallis | Friedman |
Each one is followed by its own post-hoc procedure, never by a borrowed one: one-way ANOVA by Tukey's HSD, Welch's ANOVA by Games-Howell, the trimmed mean ANOVA by pairwise Yuen tests, Kruskal-Wallis by Dunn's test, repeated measures ANOVA by pairwise paired t-tests and Friedman by Conover's test. A rank-based omnibus test is therefore never followed by a parametric comparison.
Direction is set once, by the order of group_lv, and control_label is the
second way of stating that order: the level it names moves to the front and
the rest keep the order they were given. The move carries further here than
it does in compare_two_groups(), since the reference is the denominator of
the fold change and the subtracted side of every post-hoc contrast at the
same time, and the remaining levels keep contrasting each other in display
order. Naming a level that group_lv already holds is a correction rather
than a contradiction, which is why it is accepted here and refused by the
models, whose outcome_lv holds the two classes and nothing else.
Features that cannot be tested do not abort the run. Their row is filled with
NA and all such features are reported together in a single warning.
A sa_comparison object with the same layout
compare_two_groups() returns, plus the posthoc and pairwise slots
that only a comparison of three or more levels has. Its elements are
analysis"multi_group_comparison".
featuresThe feature names, in the row order every table uses.
designgroup_lv, paired, pairing, n_dropped and
unmatched_ids.
parametersThe analysis choices as used, plus n_posthoc, the
number of features that entered the pairwise stage per test.
effectOne row per feature, described below.
testsThe omnibus tables, one row per feature.
posthocOne table per omnibus test, one row per feature and pair
of levels, carrying features, contrast, group1, group2, n1,
n2, estimate, stderr, statistic, df, pval, pval_adj,
lower_conf and upper_conf. Absent when posthoc = FALSE.
pairwiseThe same numbers one contrast at a time, described
below. Absent when posthoc = FALSE.
test_infoPer test, the method id, a readable label and the
post-hoc procedure that followed it.
diagnosticsAssumption checks, or NULL.
metadatapackage_version, r_version, platform and an
ISO-8601 timestamp.
The effect table holds n_used, n_groups, ref_center (the centre of
group_lv[1]), extreme_level, extreme_center, fold_change and
log2fc. extreme_level is whichever level sits furthest from the
reference on the log2 scale, and the ratio puts it over the reference, so a
positive log2fc means that level is the higher one. group_lv[1] is the
denominator here exactly as it is in compare_two_groups(): the first
level is the reference in both. The post-hoc contrasts subtract the
reference for the same reason, so a feature the treatment raised is
positive in effect$log2fc and in its post-hoc estimate alike.
Keeping the column named log2fc means estimate_significance() and
draw_volcano_plot() work on a multi-group result unchanged.
The omnibus tables carry lower_conf and upper_conf as required by the
result contract, and both are NA. An omnibus test states that the levels
are not all alike; it does not state by how much, and there is no single
quantity for an interval to be about. The intervals of a multi-group
comparison live in $posthoc, where each row is one contrast and does have a
scale of its own.
Only features whose omnibus pval_adj clears posthoc_alpha are compared
pairwise, and a feature that did not qualify is absent from the post-hoc
table rather than present with NA. An absent row means the question was
never asked; an NA row means it was asked and could not be answered, and
the two should not look the same. parameters$n_posthoc records how many
features entered each stage.
Tukey's HSD and Games-Howell control the error rate over the whole set of
contrasts through the studentised range, so pval_adj equals pval for
those two and posthoc_p_adjust is not applied. Dunn, Conover, pairwise
Yuen and pairwise paired t-tests are adjusted across the contrasts within
each feature.
$posthoc stacks every contrast of every feature into one long table, which
is the honest record of what was asked but an awkward shape for a reader who
came for a single comparison. $pairwise holds the same numbers keyed first
by test and then by contrast, so
res$pairwise$anova_test[["virginica - setosa"]] is that one comparison
across all features.
Those tables are rectangular where $posthoc is ragged: each holds every
feature, in the order features fixes, so contrasts can be lined up against
each other and against the omnibus tables by position. A feature that did not
qualify is present with its inference columns NA.
They add fold_change and log2fc, which no post-hoc procedure reports,
being the ratio of the two group centres rather than anything a test
produced. The ratio puts group1 over group2, matching the direction
estimate reads in, so the two always agree in sign. Note that this is a
different quantity from effect$log2fc, which compares the most extreme
level rather than a named pair, though the two now point the same way: both
divide by the reference. A feature that never entered the pairwise stage
still has its ratio: dividing two centres does not require a test to have
been run.
estimate_significance() reads these tables with by = "contrast", whose
significance element is then one verdict table per contrast rather than one
table.
A within-subject omnibus test needs a complete rectangle, so id is required
and subjects missing any condition are dropped whole rather than partially
used. The number dropped is reported in design$unmatched_ids. Missing values
are then handled per feature: a subject with NA on one feature is left out
of that feature only, which is why n_used can differ between features.
Row order pairing, which compare_two_groups() allows, is deliberately not
offered here. With two groups it is at least well defined; with three or more
it would also have to assume every condition is stored in the same subject
order, and there is no way to notice when it is not.
Welch, B. L. (1951). On the comparison of several mean values: an alternative approach. Biometrika, 38(3-4), 330-336.
Kruskal, W. H. and Wallis, W. A. (1952). Use of ranks in one-criterion variance analysis. Journal of the American Statistical Association, 47(260), 583-621.
Tukey, J. W. (1949). Comparing individual means in the analysis of variance. Biometrics, 5(2), 99-114.
Games, P. A. and Howell, J. F. (1976). Pairwise multiple comparison procedures with unequal n's and/or variances: a Monte Carlo study. Journal of Educational Statistics, 1(2), 113-125.
Dunn, O. J. (1964). Multiple comparisons using rank sums. Technometrics, 6(3), 241-252.
Conover, W. J. (1999). Practical Nonparametric Statistics, 3rd edition.
compare_two_groups() for exactly two levels,
diagnose_distribution() for the assumption checks on their own, and
draw_forest_plot() to draw the result.
## Independent samples: all three iris species feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") res <- compare_multiple_groups( data = iris, feats = feats, group = iris$Species, group_lv = c("setosa", "versicolor", "virginica") ) res res$tests$anova_test res$tests$kruskal_test ## The pairwise stage, one row per feature and pair head(res$posthoc$anova_test) ## The same numbers one contrast at a time, every feature present. Setosa is ## the reference, so it is the level every contrast subtracts. names(res$pairwise$anova_test) res$pairwise$anova_test[["virginica - setosa"]] ## Setosa is the reference, so a positive log2fc means the most extreme ## other species is the larger one. res$effect ## Repeated conditions: each ChickWeight chick weighed at several times chicks <- ChickWeight[ChickWeight$Time %in% c(0, 6, 12, 18), ] rep_res <- compare_multiple_groups( data = data.frame(weight = chicks$weight), feats = "weight", group = paste0("day", chicks$Time), group_lv = c("day0", "day6", "day12", "day18"), id = chicks$Chick, paired = TRUE ) rep_res$tests$anova_test # Mauchly, Greenhouse-Geisser and Huynh-Feldt too rep_res$posthoc$kruskal_test## Independent samples: all three iris species feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") res <- compare_multiple_groups( data = iris, feats = feats, group = iris$Species, group_lv = c("setosa", "versicolor", "virginica") ) res res$tests$anova_test res$tests$kruskal_test ## The pairwise stage, one row per feature and pair head(res$posthoc$anova_test) ## The same numbers one contrast at a time, every feature present. Setosa is ## the reference, so it is the level every contrast subtracts. names(res$pairwise$anova_test) res$pairwise$anova_test[["virginica - setosa"]] ## Setosa is the reference, so a positive log2fc means the most extreme ## other species is the larger one. res$effect ## Repeated conditions: each ChickWeight chick weighed at several times chicks <- ChickWeight[ChickWeight$Time %in% c(0, 6, 12, 18), ] rep_res <- compare_multiple_groups( data = data.frame(weight = chicks$weight), feats = "weight", group = paste0("day", chicks$Time), group_lv = c("day0", "day6", "day12", "day18"), id = chicks$Chick, paired = TRUE ) rep_res$tests$anova_test # Mauchly, Greenhouse-Geisser and Huynh-Feldt too rep_res$posthoc$kruskal_test
Tests each feature against mu and returns a parametric, a rank-based and a
proportion result side by side, in the same shape compare_two_groups()
uses. There is no second group here, so the reference is a number the user
supplies rather than a set of observations.
compare_one_sample( data, feats, mu = 0, p = 0.5, success = 1, alternative = c("two.sided", "less", "greater"), conf_level = 0.95, fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", diagnose = TRUE )compare_one_sample( data, feats, mu = 0, p = 0.5, success = 1, alternative = c("two.sided", "less", "greater"), conf_level = 0.95, fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", diagnose = TRUE )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
mu |
Hypothesised value for the mean and the pseudo-median. A single
number applied to every feature, read on the same scale as |
p |
Hypothesised proportion for |
success |
The value counted as a success when a feature is binary. Defaults to 1, so a 0/1 coded column needs nothing further. |
alternative |
Direction of the alternative hypothesis, one of
|
conf_level |
Confidence level for all reported intervals. |
fc_mean |
Which centre the fold change divides |
input_scale |
The scale |
p_adjust |
Multiplicity adjustment applied across |
diagnose |
Logical. If |
t_testOne-sample t-test on the mean.
wilcox_testOne-sample Wilcoxon signed-rank test, with the
Hodges-Lehmann pseudo-median. It tests location without assuming
normality, but it does assume the distribution of x - mu is symmetric.
prop_testScore test of the proportion of successes against p.
It only applies to features that are binary, and features that are not
come back as NA rather than being silently coerced.
Missing values are dropped per feature, so n_used varies between features
when the data are incomplete. Features that cannot be tested do not abort the
run: their row is filled with NA and all such features are reported
together in a single warning.
A sa_comparison object with the layout described in
compare_two_groups(), with two differences. design carries mu,
p and success instead of group_lv, since there are no groups, and
effect holds n_used, center (the centre fc_mean selected), mu,
diff, fold_change and log2fc, the ratio being that centre over mu.
Every column of effect is on the original measurement scale, so with
input_scale = "log2" its center, mu and diff are back-transformed
and differ from the same-named columns of tests$t_test, which stay on the
scale the tests ran on. There is no posthoc or pairwise slot: a single
sample has no pair of levels to contrast.
Every test table starts with n_used and carries pval, pval_adj,
lower_conf and upper_conf. The remaining columns are:
t_testcenter, mu, diff, stderr, t_stat, df and
cohens_d, the mean difference over the sample standard deviation.
wilcox_testhl_shift, the Hodges-Lehmann pseudo-median, and
v_stat.
prop_testn_success, proportion, p, diff, chi_sq,
df and cohens_h. The interval is a Wilson score interval, which
stays inside [0, 1] where a Wald interval does not.
fold_change is center / mu and log2fc its base-2 logarithm, so both are
undefined when mu is zero, which is also its most common value. Both
columns are NA in that case rather than infinite, a message says so, and
estimate_significance() will call every feature undecided. Reporting Inf
would read as an infinitely large increase when what actually happened is
that the question has no answer.
This cannot arise under input_scale = "log2". There the reference is
2^mu, which is positive whatever mu is, and mu = 0 simply means a
reference of 1. Under the default fc_mean = "geom" the whole row reduces to
log2fc = mean(x) - mu.
Student (1908). The probable error of a mean. Biometrika, 6(1), 1-25.
Wilcoxon, F. (1945). Individual comparisons by ranking methods. Biometrics Bulletin, 1(6), 80-83.
Wilson, E. B. (1927). Probable inference, the law of succession, and statistical inference. Journal of the American Statistical Association, 22(158), 209-212.
compare_two_groups() for two groups and
compare_multiple_groups() for three or more.
## Are the iris measurements different from 3 cm? res <- compare_one_sample( data = iris, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), mu = 3 ) res res$tests$t_test res$tests$wilcox_test res$effect ## A binary feature reaches the proportion test; a continuous one does not ## and comes back NA with a warning rather than being coerced. cars <- mtcars cars$am <- as.numeric(cars$am) suppressWarnings( compare_one_sample(cars, c("am", "vs", "mpg"), mu = 0.5, p = 0.5) )$tests$prop_test ## One-sided: is mileage above 20 mpg? compare_one_sample(mtcars, "mpg", mu = 20, alternative = "greater")$tests$t_test## Are the iris measurements different from 3 cm? res <- compare_one_sample( data = iris, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), mu = 3 ) res res$tests$t_test res$tests$wilcox_test res$effect ## A binary feature reaches the proportion test; a continuous one does not ## and comes back NA with a warning rather than being coerced. cars <- mtcars cars$am <- as.numeric(cars$am) suppressWarnings( compare_one_sample(cars, c("am", "vs", "mpg"), mu = 0.5, p = 0.5) )$tests$prop_test ## One-sided: is mileage above 20 mpg? compare_one_sample(mtcars, "mpg", mu = 20, alternative = "greater")$tests$t_test
Compares exactly two group levels across any number of numeric features and returns a parametric, a rank-based and a robust test side by side, together with the fold change between the two groups. Nothing is chosen on the user's behalf: reporting all of them makes disagreement between them visible, which is the situation where the choice of test actually matters.
compare_two_groups( data, feats, group, group_lv, control_label = group_lv[1], id = NULL, alternative = c("two.sided", "less", "greater"), paired = FALSE, conf_level = 0.95, tr = 0.2, fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", diagnose = TRUE )compare_two_groups( data, feats, group, group_lv, control_label = group_lv[1], id = NULL, alternative = c("two.sided", "less", "greater"), paired = FALSE, conf_level = 0.95, tr = 0.2, fc_mean = c("arith", "geom"), input_scale = c("raw", "log2"), p_adjust = "BH", diagnose = TRUE )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of exactly two group levels. The first is
the reference, treated as |
control_label |
The level to hold as the reference. Naming it moves that
level to the front of |
id |
Optional pairing key with one entry per row of |
alternative |
Direction of the alternative hypothesis, one of
|
paired |
Logical. If |
conf_level |
Confidence level for all reported intervals. |
tr |
Trimming proportion for Yuen's test, in |
fc_mean |
Which centre the fold change divides, |
input_scale |
The scale |
p_adjust |
Multiplicity adjustment applied across |
diagnose |
Logical. If |
Which member of each family is used depends on paired:
| family | paired = FALSE |
paired = TRUE |
| t | Welch's t-test | Paired t-test |
| Wilcoxon | Rank-sum (Mann-Whitney U) | Signed-rank |
| robust | Brunner-Munzel | Yuen's trimmed mean (dependent) |
Direction is set once, by the order of group_lv, and every quantity in the
result follows it. The first level is the reference, as it is in
compare_multiple_groups(), so alternative = "greater" tests whether
group_lv[2] exceeds group_lv[1] in all three families, and mean_diff,
hl_shift, trim_diff, fold_change and relative_effect are all above
their null value when group_lv[2] is the larger group.
control_label is the second way of stating that order. It names the level
to hold as the reference and moves it to the front, so the direction can be
flipped without the two levels being retyped in the other order. Because
group_lv says which levels take part as well as which one is the
reference, naming the second one here is a correction rather than a
contradiction, which is where the comparisons differ from the models: an
outcome_lv holds nothing but the two classes, so fit_logistic_regression()
rejects a control_label that disagrees with it.
Features that cannot be tested do not abort the run. Their row is filled
with NA and all such features are reported together in a single warning.
Informational engine warnings, such as an exact p-value being unavailable
because of ties, are grouped into one message().
Missing values are handled per feature: independent samples drop NA within
each group, paired samples keep only complete pairs. n_x, n_y and
n_used therefore vary between features when the data are incomplete. The
fold change is computed from those same observations, so it never rests on a
different subset of the data than the p-value beside it.
A sa_comparison object: a plain list, so it survives being written
out as JSON and read back in another language, with an S3 class on top that
only supplies print(). Its elements are
analysis"two_group_comparison".
featuresThe feature names, in the row order every table uses.
designgroup_lv, paired, pairing ("order", "id" or
NA when not paired), n_dropped (rows removed for belonging to a
level outside group_lv) and unmatched_ids.
parametersalternative, conf_level, tr, fc_mean,
input_scale and p_adjust, as used.
effectOne row per feature: x_center, y_center (the centres
fc_mean selected for group_lv[2] and for the reference
group_lv[1]), fold_change and log2fc.
testst_test, wilcox_test and robust_test, described
below.
test_infoPer test, the method id, a readable label and
whether it was the paired variant.
diagnosticsAssumption checks, or NULL when diagnose is
FALSE.
metadatapackage_version, r_version, platform and an
ISO-8601 timestamp.
There is no posthoc or pairwise slot. With two groups the omnibus
comparison is already the only contrast there is, so the question a
post-hoc stage answers never arises. compare_multiple_groups() is where
those two slots appear.
Every table in tests has one row per feature and starts with features,
the per-group sample sizes n_x / n_y and n_used (total observations
for independent samples, complete pairs for paired samples), and carries
pval, pval_adj, lower_conf and upper_conf. Every column named for
x or y reads the same way throughout: x is group_lv[2] and y is
the reference group_lv[1]. The remaining columns are:
t_testx_mean, y_mean, mean_diff, stderr, t_stat,
df. Group means are computed directly, so the columns are identical
for paired and independent designs.
wilcox_testhl_shift (Hodges-Lehmann location shift, the
pseudo-median of differences when paired) and w_stat.
robust_test, independentrelative_effect
(P(X > Y) + 0.5 * P(X = Y), above 0.5 when group_lv[2] is the
larger group), bm_stat and df. The interval is on the probability
scale, so a one-sided alternative leaves it open at 0 or at 1 rather
than at infinity.
robust_test, pairedx_trim_mean, y_trim_mean, trim_diff,
stderr, yuen_stat, df and robust_dz, a robust counterpart of
Cohen's dz.
With paired = TRUE and no id, the only available information is row
order: the first row of group_lv[1] is matched with the first row of
group_lv[2], and so on. Both groups must then have the same number of
rows. Note that this cannot detect rows that have been reordered, so a data
set sorted differently in each group would produce wrong pairs and no
complaint. Passing id removes that failure mode: pairs are matched on the
key, ids appearing in only one group are dropped with a message, and an id
repeated within a group is an error.
A ratio only means something on the scale the measurement was made on.
Dividing two means of already logged values answers a different question and
can even come out with the wrong sign: two log2 centres of -1 and -2 are a
two-fold increase, but their ratio, 0.5, reads as a two-fold decrease. With
input_scale = "log2" each observation is raised back through 2^x before
the centres are taken, so fold_change and log2fc mean the same thing they
do for raw input and fold_change still equals x_center / y_center.
Under the default fc_mean = "geom" this reduces exactly to
log2fc = mean(x) - mean(y), the usual definition for log-scale data.
fc_mean = "arith" is accepted but averages the back-transformed values,
which the largest observations dominate, and is not the conventional log2
fold change.
Only the effect table is converted. The tests run on the values as
supplied, which is the reason for logging them in the first place, so with
input_scale = "log2" the centres in effect are on the original scale
while x_mean and y_mean in tests$t_test are on the log2 scale. For raw
input with fc_mean = "arith" those columns hold the same numbers; here they
do not.
Welch, B. L. (1947). The generalization of Student's problem when several different population variances are involved. Biometrika, 34(1-2), 28-35.
Wilcoxon, F. (1945). Individual comparisons by ranking methods. Biometrics Bulletin, 1(6), 80-83.
Mann, H. B. and Whitney, D. R. (1947). On a test of whether one of two random variables is stochastically larger than the other. Annals of Mathematical Statistics, 18(1), 50-60.
Brunner, E. and Munzel, U. (2000). The nonparametric Behrens-Fisher problem: asymptotic theory and a small-sample approximation. Biometrical Journal, 42(1), 17-25.
Yuen, K. K. (1974). The two-sample trimmed t for unequal population variances. Biometrika, 61(1), 165-170.
estimate_significance() to reduce the result to one significance
verdict per feature, draw_grouped_boxplot() to visualise the same input,
and simulate_two_groups() for input whose answer is known in advance.
## Independent samples: two of the three iris species iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups( data = iris2, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris2$Species, group_lv = c("versicolor", "virginica") ) res res$tests$t_test res$tests$robust_test res$effect ## setosa is perfectly separated from the others on petal size, which leaves ## the Brunner-Munzel variance at zero. Those rows come back NA with a ## warning rather than aborting the other features. suppressWarnings( compare_two_groups( data = iris[iris$Species != "virginica", ], feats = c("Sepal.Length", "Petal.Length"), group = iris$Species[iris$Species != "virginica"], group_lv = c("setosa", "versicolor") )$tests$robust_test ) ## Paired samples: sleep holds 10 subjects under 2 drugs, listed in the same ## subject order within each group. paired_res <- compare_two_groups( data = sleep["extra"], feats = "extra", group = sleep$group, group_lv = c("1", "2"), paired = TRUE, alternative = "less" ) paired_res$tests$robust_test ## Same data with the second group shuffled. Row order pairing silently uses ## the wrong partners, while `id` recovers the correct result. shuffled <- rbind(sleep[1:10, ], sleep[10 + c(4, 9, 1, 7, 2, 10, 3, 6, 8, 5), ]) by_order <- compare_two_groups( data = shuffled["extra"], feats = "extra", group = shuffled$group, group_lv = c("1", "2"), paired = TRUE ) by_id <- compare_two_groups( data = shuffled["extra"], feats = "extra", group = shuffled$group, group_lv = c("1", "2"), id = shuffled$ID, paired = TRUE ) ## The means agree but the paired standard error, and so the p-value, do not. rbind(order = by_order$tests$t_test, id = by_id$tests$t_test)## Independent samples: two of the three iris species iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups( data = iris2, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris2$Species, group_lv = c("versicolor", "virginica") ) res res$tests$t_test res$tests$robust_test res$effect ## setosa is perfectly separated from the others on petal size, which leaves ## the Brunner-Munzel variance at zero. Those rows come back NA with a ## warning rather than aborting the other features. suppressWarnings( compare_two_groups( data = iris[iris$Species != "virginica", ], feats = c("Sepal.Length", "Petal.Length"), group = iris$Species[iris$Species != "virginica"], group_lv = c("setosa", "versicolor") )$tests$robust_test ) ## Paired samples: sleep holds 10 subjects under 2 drugs, listed in the same ## subject order within each group. paired_res <- compare_two_groups( data = sleep["extra"], feats = "extra", group = sleep$group, group_lv = c("1", "2"), paired = TRUE, alternative = "less" ) paired_res$tests$robust_test ## Same data with the second group shuffled. Row order pairing silently uses ## the wrong partners, while `id` recovers the correct result. shuffled <- rbind(sleep[1:10, ], sleep[10 + c(4, 9, 1, 7, 2, 10, 3, 6, 8, 5), ]) by_order <- compare_two_groups( data = shuffled["extra"], feats = "extra", group = shuffled$group, group_lv = c("1", "2"), paired = TRUE ) by_id <- compare_two_groups( data = shuffled["extra"], feats = "extra", group = shuffled$group, group_lv = c("1", "2"), id = shuffled$ID, paired = TRUE ) ## The means agree but the paired standard error, and so the p-value, do not. rbind(order = by_order$tests$t_test, id = by_id$tests$t_test)
Runs the normality tests, the homogeneity of variance tests and the outlier
screen together, and reports them as one object. The same checks are attached
automatically to compare_two_groups() and compare_multiple_groups(), so
an assumption is never silently ignored; this function is for looking at them
on their own, before a test has been chosen.
diagnose_distribution( data, feats, group = NULL, group_lv = NULL, alpha = 0.05, criterion = c("iqr", "robust_z", "grubbs"), iqr_multiplier = 1.5, z_threshold = 3.5, center = c("median", "mean", "trimmed"), trim = 0.1 )diagnose_distribution( data, feats, group = NULL, group_lv = NULL, alpha = 0.05, criterion = c("iqr", "robust_z", "grubbs"), iqr_multiplier = 1.5, z_threshold = 3.5, center = c("median", "mean", "trimmed"), trim = 0.1 )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Optional grouping vector with one entry per row of |
group_lv |
Group levels to keep, in display order. Defaults to the
sorted unique values of |
alpha |
Threshold applied to the p-values when setting the |
criterion, iqr_multiplier, z_threshold
|
Passed to |
center, trim
|
Centre used by the Levene test and its trimming proportion, passed through to the Brown-Forsythe variant. |
A failed assumption never blocks an analysis and never causes a test to be swapped for another one. It changes which member of the reported test family deserves the most weight, and that judgement stays with the user. Skewed groups make the rank-based and robust members more trustworthy than the parametric one; unequal variances make Welch's and Brunner-Munzel's treatments of the same data more trustworthy than the pooled ones.
Each assumption is checked twice on purpose, by tests that fail differently:
Shapiro-Wilk is the more powerful of the two and is the one to read first. The Kolmogorov-Smirnov test is fitted against a normal with the sample's own mean and standard deviation, which makes its p-value anti-conservative, so it disagreeing with Shapiro-Wilk usually means the departure is in the tails.
The Levene test is centred on the median, the Brown-Forsythe variant, and tolerates skew. Bartlett's test is more powerful when the groups really are normal and cannot tell unequal variances apart from heavy tails when they are not. The two disagreeing is itself evidence about normality.
A sa_diagnosis object: a plain list carrying analysis,
features, design, parameters, metadata and four tables.
normalityOne row per feature and group level: features,
group, n_used, shapiro_stat, shapiro_pval, ks_stat,
ks_pval, skewness and excess_kurtosis. The level column is named
group to match summarize_descriptive_stats(), and is NA when no
grouping was supplied.
varianceOne row per feature: features, n_used, n_groups,
levene_stat, levene_df1, levene_df2, levene_pval,
bartlett_stat, bartlett_df and bartlett_pval. Zero rows when no
group was supplied.
outliersThe screen_outliers() table, one row per flagged
observation.
summaryOne row per feature: features, n_levels,
n_outliers, min_shapiro_pval, normal_ok and variance_ok. The
two flags are NA when the corresponding test could not be run.
The result is deliberately not an sa_comparison. Normality is a property
of one sample and homogeneity a property of a set of them, so the two
tables have different numbers of rows and would not fit a contract built
around one row per feature.
Shapiro, S. S. and Wilk, M. B. (1965). An analysis of variance test for normality (complete samples). Biometrika, 52(3-4), 591-611.
Massey, F. J. (1951). The Kolmogorov-Smirnov test for goodness of fit. Journal of the American Statistical Association, 46(253), 68-78.
Brown, M. B. and Forsythe, A. B. (1974). Robust tests for the equality of variances. Journal of the American Statistical Association, 69(346), 364-367.
Bartlett, M. S. (1937). Properties of sufficiency and statistical tests. Proceedings of the Royal Society A, 160(901), 268-282.
screen_outliers() for the outlier stage on its own, and
compare_multiple_groups(), whose $diagnostics slot holds the same
tables for the data it tested.
feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## Within species, so a genuine species difference is not read as skew d <- diagnose_distribution(iris, feats, iris$Species) d d$normality d$variance d$summary ## Petal length pooled over species is strongly bimodal, which every ## normality test picks up once the grouping is taken away. diagnose_distribution(iris, "Petal.Length")$normalityfeats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## Within species, so a genuine species difference is not read as skew d <- diagnose_distribution(iris, feats, iris$Species) d d$normality d$variance d$summary ## Petal length pooled over species is strongly bimodal, which every ## normality test picks up once the grouping is taken away. diagnose_distribution(iris, "Petal.Length")$normality
Draws the distribution of a single feature for two group levels back to back, on a shared set of breaks. The first level runs left from the centre line and the second one right, so the two shapes can be compared bin by bin instead of read off two separate panels.
draw_butterfly_hist( data, feat, group, group_lv, breaks = "Sturges", scale = c("count", "proportion", "density"), type = c("freq", "dens", "both"), dens_adjust = 1, dens_lwd = 2, dens_col = NULL, dens_alpha = 0.45, col = c("#4575B4", "#D73027"), border = "white", xlab = NULL, ylab = NULL, main = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, cex.legend = 1.1, legend.position = "topright", xlim = NULL, ylim = NULL, margin = c(5, 5, 4, 3), out_statistics = TRUE, ... )draw_butterfly_hist( data, feat, group, group_lv, breaks = "Sturges", scale = c("count", "proportion", "density"), type = c("freq", "dens", "both"), dens_adjust = 1, dens_lwd = 2, dens_col = NULL, dens_alpha = 0.45, col = c("#4575B4", "#D73027"), border = "white", xlab = NULL, ylab = NULL, main = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, cex.legend = 1.1, legend.position = "topright", xlim = NULL, ylim = NULL, margin = c(5, 5, 4, 3), out_statistics = TRUE, ... )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feat |
Name of the numeric column in |
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of exactly two group levels. The first is drawn on the left, the second on the right. Rows belonging to any other level are dropped. |
breaks |
Histogram break specification, shared by both groups. Accepts
a character rule supported by |
scale |
What the bar length means: |
type |
Which layers to draw: |
dens_adjust |
Bandwidth multiplier passed to |
dens_lwd |
Line width of the density outline. |
dens_col |
Colour of the density outline, either one colour or one per
group level. |
dens_alpha |
Opacity of the density fill, between 0 and 1. The default
leaves the shape half transparent, so under |
col |
Length-2 vector of fill colours, for the left and right group. |
border |
Colour of the bar borders. |
xlab, ylab, main
|
Axis labels and title. |
cex.lab, cex.axis, cex.main, cex.legend
|
Character expansion for the axis labels, the axis annotation, the title and the legend. |
legend.position |
Position passed to |
xlim, ylim
|
Numeric length-2 ranges for the bar axis and the value
axis, or |
margin |
Plot margins in lines, passed to |
out_statistics |
If |
... |
Additional arguments passed to |
The left group is drawn at negative coordinates so that both distributions share one axis, but the tick labels are the absolute values, so a bar is read the same way on either side.
A density curve and a bar can only be read against the same axis when the bar
is a density too: a count or a proportion per bin scales with the bin width,
which the curve knows nothing about. So type = "dens" and type = "both"
move scale to "density", and reject a scale that was asked for
explicitly and says otherwise, rather than drawing two incomparable shapes.
The plot starts from the device background rather than imposing a theme of
its own, as draw_volcano_plot() does. Only two colours are needed here, so
there is no dark variant; that is reserved for draw_grouped_boxplot(),
where three or more group colours have to stay apart.
Only the margins are restored on exit, not every graphical parameter, so the
coordinate system survives the call and graphics::abline(),
graphics::points() and friends can still add to the finished plot.
If out_statistics = FALSE, NULL invisibly. Otherwise a list,
invisibly:
bin_summary_statsOne row per bin, with bin_start, bin_end,
bin_mid and one column per group level holding the plotted bar
length. What that length means follows scale.
group_summary_statsOne column per group level with rows n
(finite values used), n_dropped (missing or non-finite values left
out), min and max.
group_histsOne "histogram" object per group level, named by
the level, exactly as graphics::hist() returns them, so
plot(res$group_hists[[lv]]) redraws that group on its own. Both
groups are binned on the shared breaks, so these are not what
calling graphics::hist() on one group alone would give: that would
pick its own breaks from that group only.
group_densitiesPresent only when type is not "freq". One
"density" object per group level, named by the level, as
stats::density() returns them. The bins are still reported in
bin_summary_stats even when no bars were drawn.
draw_grouped_boxplot() for several features at once, and
compare_two_groups() to test the difference the plot shows.
iris2 <- iris[iris$Species != "setosa", ] draw_butterfly_hist( data = iris2, feat = "Petal.Length", group = iris2$Species, group_lv = c("versicolor", "virginica"), breaks = 12, scale = "proportion", main = "virginica vs versicolor" ) ## Counts, plus the numbers behind the bars res <- draw_butterfly_hist(iris2, "Sepal.Width", iris2$Species, c("versicolor", "virginica")) res$bin_summary_stats res$group_summary_stats ## Each group also comes back as a plain histogram object plot(res$group_hists$virginica) ## Bars with a kernel density estimate on top, both on the density scale res <- draw_butterfly_hist( data = iris2, feat = "Petal.Length", group = iris2$Species, group_lv = c("versicolor", "virginica"), breaks = 12, type = "both", main = "Petal length, bars and density" ) res$group_densities$virginica ## The smoothed shapes on their own draw_butterfly_hist(iris2, "Petal.Length", iris2$Species, c("versicolor", "virginica"), type = "dens")iris2 <- iris[iris$Species != "setosa", ] draw_butterfly_hist( data = iris2, feat = "Petal.Length", group = iris2$Species, group_lv = c("versicolor", "virginica"), breaks = 12, scale = "proportion", main = "virginica vs versicolor" ) ## Counts, plus the numbers behind the bars res <- draw_butterfly_hist(iris2, "Sepal.Width", iris2$Species, c("versicolor", "virginica")) res$bin_summary_stats res$group_summary_stats ## Each group also comes back as a plain histogram object plot(res$group_hists$virginica) ## Bars with a kernel density estimate on top, both on the density scale res <- draw_butterfly_hist( data = iris2, feat = "Petal.Length", group = iris2$Species, group_lv = c("versicolor", "virginica"), breaks = 12, type = "both", main = "Petal length, bars and density" ) res$group_densities$virginica ## The smoothed shapes on their own draw_butterfly_hist(iris2, "Petal.Length", iris2$Species, c("versicolor", "virginica"), type = "dens")
A corrplot: one cell per pair of features, coloured by the coefficient on a
fixed -1 to 1 scale, with both axes in one order so that the diagonal runs
corner to corner and blocks of features that move together sit next to each
other. Given the p-values as well, the pairs that did not clear sig_level
are drawn as blank cells, so that what is left coloured is what there is
evidence for.
draw_corrplot( cor_matrix, method = NULL, pvalue = NULL, use_adjusted = TRUE, sig_level = 0.05, cluster = TRUE, hclust_method = c("average", "complete", "ward.D2"), zlim = c(-1, 1), anno = TRUE, main = NULL, cex.anno = 1, cex.axis = 0.9, cex.main = 1.5, cex.legend = 1.2, ... )draw_corrplot( cor_matrix, method = NULL, pvalue = NULL, use_adjusted = TRUE, sig_level = 0.05, cluster = TRUE, hclust_method = c("average", "complete", "ward.D2"), zlim = c(-1, 1), anno = TRUE, main = NULL, cex.anno = 1, cex.axis = 0.9, cex.main = 1.5, cex.legend = 1.2, ... )
cor_matrix |
The result of |
method |
Which coefficient to draw when |
pvalue |
Matrix of p-values laid out like |
use_adjusted |
Whether to read |
sig_level |
Largest p-value a cell may have and still be drawn. Cells above it are blanked. The diagonal is never blanked, a feature not being tested against itself. |
cluster |
Whether to reorder the features by clustering them. |
hclust_method |
Linkage handed to |
zlim |
Numeric length-2 range the colours span. The default is the range a correlation can take, so that the same colour means the same strength from one plot to the next. |
anno |
Whether to write each coefficient on its cell, rounded to two
decimal places by |
main |
Plot title. |
cex.anno |
Character expansion for those cell labels, relative to the smaller of the feature axis label sizes. |
cex.axis, cex.main, cex.legend
|
Character expansion for the axis labels,
title and colour key, passed to |
... |
Passed to |
draw_heatmap() draws it. This function decides what it is handed: the matrix
unscaled, the colour range fixed at the range a correlation can take, one
clustering shared by the two axes, and the blanking applied afterwards.
The distance the clustering runs on is 1 - cor(), the same one
cluster_hclust() and draw_heatmap() mean by dist_method = "correlation", so a corrplot and a heatmap of the same features group them
the same way. It is computed once and both axes are permuted by it. A matrix
holding an NA, which is what a feature with no variance leaves behind, has
no distance for that feature, and rather than fail the features are left in
their input order with a message saying so.
Blanking happens after the clustering rather than before it. A cell removed for its p-value would otherwise change the tree, and the picture would no longer be the matrix the reader is being shown.
A cell whose p-value is NA, a pair that could not be tested, is left as it
arrived rather than blanked: there is no evidence against it either, and the
coefficient beside it is usually already NA.
The matrix is symmetric, so the transpose draw_heatmap() takes on the way in
leaves it unchanged and the features come out on both axes.
A list, invisibly: everything draw_heatmap() returns, and beside it
corrThe matrix as it was drawn, in the drawn order and with the
blanked cells NA.
pvalueThe p-values in that same order, or NULL.
orderThe permutation of the input the clustering chose.
hclustThe stats::hclust() object behind it, or NULL when the
features were not clustered.
n_maskedHow many cells were blanked.
summarize_association_stats(), which produces what this draws, and
draw_heatmap(), which draws it.
feats <- c("mpg", "cyl", "disp", "hp", "drat", "wt") res <- summarize_association_stats(mtcars, feats, methods = "pearson") ## Every pair, with the features clustered so that the blocks sit together drawn <- draw_corrplot(res, main = "mtcars") drawn$order ## Only the pairs that cleared the adjustment at 1% draw_corrplot(res, sig_level = 0.01, cex.anno = 0.8, cex.axis = 0.7) ## A bare matrix, in the order it arrives and with nothing to blank draw_corrplot(stats::cor(mtcars[feats]), cluster = FALSE)feats <- c("mpg", "cyl", "disp", "hp", "drat", "wt") res <- summarize_association_stats(mtcars, feats, methods = "pearson") ## Every pair, with the features clustered so that the blocks sit together drawn <- draw_corrplot(res, main = "mtcars") drawn$order ## Only the pairs that cleared the adjustment at 1% draw_corrplot(res, sig_level = 0.01, cex.anno = 0.8, cex.axis = 0.7) ## A bare matrix, in the order it arrives and with nothing to blank draw_corrplot(stats::cor(mtcars[feats]), cluster = FALSE)
Plots two coordinates of a perform_pca(), perform_tsne() or
perform_umap() result against each other. A clustering of the same frame
colours the points and a known grouping shapes them, so what the data was
found to say and what the caller already knew are read off one picture.
draw_dim_reduction_plot( reduction_result, group = NULL, group_lv = NULL, cluster_result = NULL, cluster_lv = NULL, dims = c(1L, 2L), anno_points = FALSE, dark = FALSE, asp = NULL, col = NULL, pch = NULL, cex = 1.2, xlim = NULL, ylim = NULL, xlab = NULL, ylab = NULL, main = NULL, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1, cex.anno = NULL ) ## S3 method for class 'sa_reduction' plot(x, ...)draw_dim_reduction_plot( reduction_result, group = NULL, group_lv = NULL, cluster_result = NULL, cluster_lv = NULL, dims = c(1L, 2L), anno_points = FALSE, dark = FALSE, asp = NULL, col = NULL, pch = NULL, cex = 1.2, xlim = NULL, ylim = NULL, xlab = NULL, ylab = NULL, main = NULL, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1, cex.anno = NULL ) ## S3 method for class 'sa_reduction' plot(x, ...)
reduction_result |
A reduction, as returned by |
group |
One label per point, or |
group_lv |
The levels of |
cluster_result |
A clustering of the same points, as returned by
|
cluster_lv |
One label per cluster, in the order the clustering numbers
them, or |
dims |
Which two coordinates to draw, as positions in the score table. |
anno_points |
Whether to write each point's label beside it. |
dark |
Whether to draw on a dark background. |
asp |
Aspect ratio passed to |
col |
One colour for every point, one per cluster when |
pch |
One shape for every point, one per group level when |
cex |
Size of the plotted points. |
xlim, ylim
|
Axis ranges, or |
xlab, ylab, main
|
Axis and figure labels. |
cex.axis, cex.lab, cex.main, cex.legend, cex.anno
|
Relative text sizes.
|
x |
A reduction, as returned by |
... |
Arguments passed on to |
A data.frame of the points as they were drawn, invisibly: points,
x, y, col, pch, and cluster and group when those were given. The
resolved colouring is carried as a "view" attribute, one of "both",
"cluster", "group" or "plain".
cluster_result takes the colours and group takes the shapes when both are
given, and giving both is the point of the arrangement rather than a conflict
to be resolved. With one channel alone, that channel takes the colours when
col is named; otherwise a lone clustering is coloured and a lone grouping
is shaped. The question a clustering usually raises is whether it recovered a
grouping that was known all along, and on two channels that is read directly:
one colour per shape is a clustering that found the groups, and a shape split
across colours is a group the data does not see as one thing. Give neither
and the points are drawn in the foreground colour.
The clustering has to be of the same points, which the two contracts already
promise: sa_cluster and sa_reduction both read their input through the
same function, so a mismatch is refused rather than lined up by position.
cluster_dbscan() and cluster_snn() can leave a point in no cluster at all.
Those points are grey rather than a palette colour, since a point left out is
the absence of a cluster and not a cluster of its own, and the legend counts
them on a line of their own.
A principal component analysis reports what share of the variance each of its
components carries, so its axis labels carry it too: PC1 (30.2%) is read
from $variance rather than recomputed. An embedding has no such number and
its axes are labelled with their names alone. asp = 1 is what makes one unit
of the vertical axis the same length as one unit of the horizontal, which is
worth setting when the distance between two points is what is being read.
perform_pca() for the coordinates and cluster_kmeans() for the
labels, and draw_heatmap(), which shows the same wide input a cell at a
time rather than a point at a time.
res <- perform_pca(iris[1:4]) ## What was known all along, as shapes. Name `col` to colour the levels ## instead. draw_dim_reduction_plot(res, group = iris$Species) draw_dim_reduction_plot(res, group = iris$Species, col = c("#E69F00", "#56B4E9", "#009E73")) ## What the data was found to say, as colours. The clustering was never shown ## the species, so one colour per shape is its own finding. cl <- cluster_kmeans(iris[1:4], n_clust = 3, seed = 1) drawn <- draw_dim_reduction_plot(res, group = iris$Species, cluster_result = cl) table(cluster = drawn$cluster, species = drawn$group) ## Names for the clusters the legend would otherwise number. draw_dim_reduction_plot(res, cluster_result = cl, cluster_lv = c("A", "B", "C")) ## A density method can place no point at all, and those are grey. db <- cluster_dbscan(iris[1:4]) draw_dim_reduction_plot(res, cluster_result = db, asp = 1) ## The features as the points, labelled, on the third and fourth components. by_feat <- perform_pca(iris[1:4], embedding_scale = "features") draw_dim_reduction_plot(by_feat, dims = c(1, 3), anno_points = TRUE)res <- perform_pca(iris[1:4]) ## What was known all along, as shapes. Name `col` to colour the levels ## instead. draw_dim_reduction_plot(res, group = iris$Species) draw_dim_reduction_plot(res, group = iris$Species, col = c("#E69F00", "#56B4E9", "#009E73")) ## What the data was found to say, as colours. The clustering was never shown ## the species, so one colour per shape is its own finding. cl <- cluster_kmeans(iris[1:4], n_clust = 3, seed = 1) drawn <- draw_dim_reduction_plot(res, group = iris$Species, cluster_result = cl) table(cluster = drawn$cluster, species = drawn$group) ## Names for the clusters the legend would otherwise number. draw_dim_reduction_plot(res, cluster_result = cl, cluster_lv = c("A", "B", "C")) ## A density method can place no point at all, and those are grey. db <- cluster_dbscan(iris[1:4]) draw_dim_reduction_plot(res, cluster_result = db, asp = 1) ## The features as the points, labelled, on the third and fourth components. by_feat <- perform_pca(iris[1:4], embedding_scale = "features") draw_dim_reduction_plot(by_feat, dims = c(1, 3), anno_points = TRUE)
Draws the estimate of each feature beside its confidence interval, falling
back to a bar of -log10(pval_adj) for a table that has no interval to
draw, which is every omnibus test.
draw_forest_plot( comparison_result, test = names(comparison_result$tests)[1], type = c("auto", "estimate", "posthoc", "pvalue"), feats = NULL, use_adjusted = TRUE, alpha = 0.05, sort_by = c("none", "pvalue"), dark = FALSE, xlim = NULL, xlab = NULL, main = NULL, col_signif = "#D1495B", col_plain = "#7F8C8D", cex.axis = 0.9, cex.lab = 1.1, cex.main = 1.2, cex.legend = 1, ... ) ## S3 method for class 'sa_comparison' plot(x, ...)draw_forest_plot( comparison_result, test = names(comparison_result$tests)[1], type = c("auto", "estimate", "posthoc", "pvalue"), feats = NULL, use_adjusted = TRUE, alpha = 0.05, sort_by = c("none", "pvalue"), dark = FALSE, xlim = NULL, xlab = NULL, main = NULL, col_signif = "#D1495B", col_plain = "#7F8C8D", cex.axis = 0.9, cex.lab = 1.1, cex.main = 1.2, cex.legend = 1, ... ) ## S3 method for class 'sa_comparison' plot(x, ...)
comparison_result |
A comparison result, as returned by
|
test |
Which test to draw. One of |
type |
|
feats |
Character vector of features to draw, in display order from the
top of the plot down. |
use_adjusted |
Logical. If |
alpha |
Threshold marked on the p-value view and used to colour the points of the estimate view. |
sort_by |
|
dark |
If |
xlim |
Numeric length-2 x axis range, or |
xlab, main
|
Axis and title labels. Both are derived from the result when
left |
col_signif, col_plain
|
Colours for rows at or below |
cex.axis, cex.lab, cex.main, cex.legend
|
Character expansion for the axis annotation, the axis label, the title and the legend. |
... |
Arguments the |
x |
The same comparison result, under the name the |
Only the columns the result contract guarantees are read, which is why one
function covers every scenario: it never asks whether the object came from
two groups, three groups or a single sample, only whether the table it was
handed has intervals or p-values, and every table has one or the other.
plot() on an sa_comparison is the same function under the name R users
reach for first.
Three views are available:
"estimate"A forest plot of the effect estimate and its confidence interval, one row per feature. Available whenever the chosen table has finite intervals, which the two-group and one-sample scenarios always do.
"posthoc"The same forest plot for the pairwise contrasts of a
multi-group comparison, one row per contrast. Only the first feature of
the post-hoc table is drawn unless feats names the ones to draw. An
estimate reads as group1 - group2, the direction the row label spells
out, and the reference level is the one subtracted, so a point to the
right of the guide agrees in sign with the log2fc a volcano plot of the
same comparison draws.
"pvalue"-log10() of the p-value per feature with the alpha
threshold marked. The fallback when a table has no interval to draw,
which is the case for every omnibus test.
type = "auto", the default, picks the first of those three that the chosen
table can actually support.
The function changes graphical parameters and the panel layout, and restores both on exit, so the caller's device is left as it was found. The legend lives in a narrow panel of its own on the right, where it cannot cover the rows it describes.
The estimate view marks the null value with a vertical line, at zero for a difference and at one for the Brunner-Munzel relative effect, which is the one quantity in the package whose null is not zero.
The plotted data.frame, invisibly, in the row order it was drawn,
with the view that type = "auto" resolved to attached as the attribute
"view".
draw_volcano_plot(), which plots effect size against significance
rather than estimate against interval, and draw_grouped_boxplot() for the
input data.
iris2 <- iris[iris$Species != "setosa", ] feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") res <- compare_two_groups(iris2, feats, iris2$Species, c("virginica", "versicolor")) ## Mean differences with their intervals draw_forest_plot(res) ## plot() reaches the same function plot(res) ## The same comparison read through the rank-based test draw_forest_plot(res, test = "wilcox_test", sort_by = "pvalue") ## Two features only, drawn in the order they are named, against the ## unadjusted p-value draw_forest_plot(res, feats = c("Petal.Width", "Petal.Length"), use_adjusted = FALSE) ## An axis range of your own, so that two plots can be read against each other draw_forest_plot(res, xlim = c(0, 2)) ## A multi-group omnibus table has no interval, so "auto" falls through to ## the pairwise contrasts. multi <- compare_multiple_groups(iris, feats, iris$Species, levels(iris$Species)) draw_forest_plot(multi, feats = "Petal.Length") ## Several features at once label each contrast with the feature it belongs to draw_forest_plot(multi, feats = c("Petal.Length", "Sepal.Width")) draw_forest_plot(multi, type = "pvalue")iris2 <- iris[iris$Species != "setosa", ] feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") res <- compare_two_groups(iris2, feats, iris2$Species, c("virginica", "versicolor")) ## Mean differences with their intervals draw_forest_plot(res) ## plot() reaches the same function plot(res) ## The same comparison read through the rank-based test draw_forest_plot(res, test = "wilcox_test", sort_by = "pvalue") ## Two features only, drawn in the order they are named, against the ## unadjusted p-value draw_forest_plot(res, feats = c("Petal.Width", "Petal.Length"), use_adjusted = FALSE) ## An axis range of your own, so that two plots can be read against each other draw_forest_plot(res, xlim = c(0, 2)) ## A multi-group omnibus table has no interval, so "auto" falls through to ## the pairwise contrasts. multi <- compare_multiple_groups(iris, feats, iris$Species, levels(iris$Species)) draw_forest_plot(multi, feats = "Petal.Length") ## Several features at once label each contrast with the feature it belongs to draw_forest_plot(multi, feats = c("Petal.Length", "Sepal.Width")) draw_forest_plot(multi, type = "pvalue")
Draws one bar per feature and group level, the levels side by side inside
each feature's cluster and a legend in a narrow panel on the right. The bar
heights are one column of summarize_descriptive_stats(), read from the same
wide input the comparison functions take, so a bar and a row of that table
are the same number and neither has to be recomputed to check the other.
draw_grouped_barplot( data, feats, group = NULL, group_lv = NULL, control_label = NULL, mainbar = c("mean", "median", "n", "n_missing", "sd", "var", "se", "cv", "mad", "skewness", "excess_kurtosis"), errorbar = c("none", "se", "sd", "ci"), conf_level = 0.95, gap = 1, lwd = 1.5, col = NULL, xlab = NULL, ylab = NULL, main = NULL, ylim = NULL, dark = FALSE, grid_lty = 1, grid_lwd = 0.25, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, cex.legend = 1.1, out_statistics = TRUE )draw_grouped_barplot( data, feats, group = NULL, group_lv = NULL, control_label = NULL, mainbar = c("mean", "median", "n", "n_missing", "sd", "var", "se", "cv", "mad", "skewness", "excess_kurtosis"), errorbar = c("none", "se", "sd", "ci"), conf_level = 0.95, gap = 1, lwd = 1.5, col = NULL, xlab = NULL, ylab = NULL, main = NULL, ylim = NULL, dark = FALSE, grid_lty = 1, grid_lwd = 0.25, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, cex.legend = 1.1, out_statistics = TRUE )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of at least two group levels, in the order
they should appear inside each cluster. Rows belonging to any other level
are dropped. |
control_label |
The level to hold as the reference. It moves to the
front of |
mainbar |
Which column of |
errorbar |
What the bars either side of a height are. |
conf_level |
Confidence level of |
gap |
Blank bar widths inserted between neighbouring clusters. |
lwd |
Line width of the error bars and of the baseline when the heights run both ways. |
col |
Fill colours for the group levels, recycled if short. |
xlab, ylab, main
|
Axis and title labels. |
ylim |
Numeric length-2 y axis range, or |
dark |
If |
grid_lty, grid_lwd
|
Line type and width of the horizontal grid. |
cex.lab, cex.axis, cex.main, cex.legend
|
Character expansion for axis labels, axis annotation, the main title and the legend. |
out_statistics |
If |
This is the summary counterpart of draw_grouped_boxplot(). A box shows the
distribution a group's observations have; a bar shows one number standing for
them, which is less of the data and is what a figure wants when the point
being made is about a location rather than a spread.
A bar is read against the zero it stands on, so a derived ylim always
includes zero and puts its headroom on the side the bars run to. Heights that
go both ways, which "skewness" and a "mean" of centred features do, get
the baseline drawn as well.
A bar whose height is NA leaves a blank rather than shifting the ones
beside it, which is what makes a group too small for a shape estimate visible
instead of silently absent.
The function changes graphical parameters and the panel layout, and restores both on exit, so the caller's device is left as it was found.
If out_statistics = FALSE, NULL invisibly. Otherwise, invisibly,
a data.frame of one row per bar in the order they were drawn, which is the
row order of summarize_descriptive_stats(): a feature's levels stay
together, in group_lv order.
features,group
Which bar the row is.
nFinite observations the bar was computed from.
valueThe bar height, the mainbar column.
lower,upper
The ends of the interval, NA under
errorbar = "none" and for a bar whose interval was not defined.
Which height and which interval were drawn are attached as the attributes
"mainbar" and "errorbar".
mainbar names any of the summary columns, but only two of them are
locations that an interval either side says something about, so errorbar is
read under mainbar rather than independently of it.
"mean"Takes every bar. "se" and "sd" are one standard error
and one standard deviation either side, and "ci" is Student's interval
at conf_level: stats::qt() on n - 1 degrees of freedom times the
standard error.
"median"Takes "ci" only, the notch interval
median +/- 1.58 * IQR / sqrt(n). That is the same interval
draw_grouped_boxplot() returns as median_confidence_stats, so a bar
and the notch of the box beside it are the same width on the same data.
"se" and "sd" describe the observations' spread about their mean and
are refused here rather than drawn around a median they are not about.
Takes "none". A height that is itself a spread, a
count or a shape has no second quantity for an interval to be about.
A refused combination is an error rather than a silently dropped interval, because the alternative is a figure that answers a question other than the one it was asked.
McGill, R., Tukey, J. W. and Larsen, W. A. (1978). Variations of box plots. The American Statistician, 32(1), 12-16.
summarize_descriptive_stats() for the table the heights come from,
draw_grouped_boxplot() for the observations behind them, and
compare_two_groups() or compare_multiple_groups() to test the
difference a pair of bars shows.
feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## Mean per species, one standard error either side bars <- draw_grouped_barplot(iris, feats, iris$Species, errorbar = "se") head(bars) ## The heights are the table's own column, so neither has to be checked ## against the other by eye. summ <- summarize_descriptive_stats(iris, feats, iris$Species) all.equal(bars$value, summ$mean) ## Student's interval, at a level of your own draw_grouped_barplot(iris, feats, iris$Species, errorbar = "ci", conf_level = 0.99) ## A median takes the notch interval draw_grouped_boxplot() notches with draw_grouped_barplot(iris, feats, iris$Species, mainbar = "median", errorbar = "ci") ## A count carries no interval, and `group_lv` picks the levels and their ## order draw_grouped_barplot(iris, feats, iris$Species, group_lv = c("virginica", "versicolor"), mainbar = "n") ## `control_label` draws the named level first without rewriting `group_lv` draw_grouped_barplot(iris, feats, iris$Species, control_label = "setosa")feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## Mean per species, one standard error either side bars <- draw_grouped_barplot(iris, feats, iris$Species, errorbar = "se") head(bars) ## The heights are the table's own column, so neither has to be checked ## against the other by eye. summ <- summarize_descriptive_stats(iris, feats, iris$Species) all.equal(bars$value, summ$mean) ## Student's interval, at a level of your own draw_grouped_barplot(iris, feats, iris$Species, errorbar = "ci", conf_level = 0.99) ## A median takes the notch interval draw_grouped_boxplot() notches with draw_grouped_barplot(iris, feats, iris$Species, mainbar = "median", errorbar = "ci") ## A count carries no interval, and `group_lv` picks the levels and their ## order draw_grouped_barplot(iris, feats, iris$Species, group_lv = c("virginica", "versicolor"), mainbar = "n") ## `control_label` draws the named level first without rewriting `group_lv` draw_grouped_barplot(iris, feats, iris$Species, control_label = "setosa")
Draws clusters of boxes with the levels of one factor side by side inside each cluster and a legend in a narrow panel on the right. A single factor gives one cluster per feature. A crossed design gives one panel per feature, with the factors after the first along the x axis, so that the crossing sits inside a panel where an interaction can be seen. Optionally returns the summary statistics behind the boxes.
draw_grouped_boxplot( data, feats, group = NULL, group_lv = NULL, factors = NULL, factor_lv = NULL, control_label = NULL, panel_by = c("feature", "factor"), panel_nrow = NULL, gap = 1, lwd = 1.5, xlab = NULL, ylab = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, ylim = NULL, main = NULL, dark = FALSE, grid_lty = 1, grid_lwd = 0.25, cex.legend = 1.1, out_statistics = TRUE )draw_grouped_boxplot( data, feats, group = NULL, group_lv = NULL, factors = NULL, factor_lv = NULL, control_label = NULL, panel_by = c("feature", "factor"), panel_nrow = NULL, gap = 1, lwd = 1.5, xlab = NULL, ylab = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, ylim = NULL, main = NULL, dark = FALSE, grid_lty = 1, grid_lwd = 0.25, cex.legend = 1.1, out_statistics = TRUE )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of at least two group levels, in the order they should appear inside each cluster. Rows belonging to any other level are dropped. |
factors |
Named list of the crossed factors, each entry either the name
of a column of |
factor_lv |
Named list giving the levels of each factor, with the
reference level first, or |
control_label |
The level to hold as the reference, one name per factor
it points at, as a named list ( |
panel_by |
Which axis the panels are over, read only under |
panel_nrow |
How many rows the panels are laid out in, or |
gap |
Blank box widths inserted between neighbouring clusters. |
lwd |
Line width of the boxes. |
xlab, ylab, main
|
Axis and title labels. |
cex.lab, cex.axis, cex.main, cex.legend
|
Character expansion for axis labels, axis annotation, the main title and the legend. |
ylim |
Numeric length-2 y axis range, shared by every panel. |
dark |
If |
grid_lty, grid_lwd
|
Line type and width of the horizontal grid. |
out_statistics |
If |
The function changes graphical parameters and the panel layout, and restores both on exit, so the caller's device is left as it was found.
If out_statistics = FALSE, NULL invisibly. Otherwise a list of
two elements, invisibly:
box_summary_statsOne data.frame per feature with rows min,
lower_bound, Q1, median, Q3, upper_bound, max and one
column per box: the group levels under group, and the cell labels of
the design under factors. The bounds are the Tukey whisker fences
Q1 - 1.5 * IQR and Q3 + 1.5 * IQR, not the drawn whisker ends.
median_confidence_statsOne data.frame per feature with rows
n, lower_conf, upper_conf, the notch interval
median +/- 1.58 * IQR / sqrt(n). n counts non-missing values.
A crossed design has three categorical axes to place and two dimensions to place them in, so one of them has to become the panels. Which one decides whether the picture shows an interaction.
panel_by = "feature", the default, gives one panel per feature. Inside it
the remaining factors run along the x axis and the primary factor is the
coloured boxes within each of those clusters, so the two factors are side
by side in one panel: an effect of the treatment that reverses between the
levels of the other factor is a pattern of colours that visibly flips a
couple of centimetres away, which is what an interaction is.
panel_by = "factor" gives the transpose: one panel per combination of the
remaining factors, with the features along the x axis. It puts every feature
of one cell together, which is what to ask for when the question is about the
features rather than about the crossing. The cost is that the two factors are
then split between the legend and the panels, and reading an interaction
means comparing a colour profile in one panel against the same in another.
Either way the boxes are the same boxes and the returned statistics are
identical; only the grouping of them into panels and clusters differs. The
cells, their order and their labels come from the same helpers
compare_factorial_groups() uses, so a box of this plot and a row of that
result are the same observations, and the columns of the returned statistics
are the cell labels the comparison and simulate_factorial_groups() both key
on. A cell holding no observation leaves its box blank rather than shifting
the ones beside it, and is reported in a message.
McGill, R., Tukey, J. W. and Larsen, W. A. (1978). Variations of box plots. The American Statistician, 32(1), 12-16.
compare_two_groups() and compare_factorial_groups() to test the
same input.
## All three iris species across the four measurements stats <- draw_grouped_boxplot( data = iris, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris$Species, group_lv = c("setosa", "versicolor", "virginica"), ylab = "cm", dark = FALSE ) stats$box_summary_stats$Sepal.Length stats$median_confidence_stats$Sepal.Length ## A crossed design: one panel per feature, the sexes along the x axis and ## the treatment the colours, so an effect that flips with sex is local. sim <- simulate_factorial_groups(n_feats = 6, n_per_cell = 8, seed = 1) cells <- draw_grouped_boxplot( data = sim$args$data, feats = sim$args$feats, factors = sim$args$factors, factor_lv = sim$args$factor_lv, ylab = "log2 abundance" ) ## One column per cell, labelled the way the answer key labels them. cells$box_summary_stats$prot_1 ## The transpose: one panel per sex, every feature together inside it. draw_grouped_boxplot( data = sim$args$data, feats = sim$args$feats, factors = sim$args$factors, factor_lv = sim$args$factor_lv, panel_by = "factor", ylab = "log2 abundance" ) ## `control_label` draws the named level first in its own factor, and takes ## the same argument the comparison does, so the two agree on where the ## reference cell sits. draw_grouped_boxplot( data = sim$args$data, feats = sim$args$feats[1:2], factors = sim$args$factors, control_label = list(sex = "female"), ylab = "log2 abundance" )## All three iris species across the four measurements stats <- draw_grouped_boxplot( data = iris, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris$Species, group_lv = c("setosa", "versicolor", "virginica"), ylab = "cm", dark = FALSE ) stats$box_summary_stats$Sepal.Length stats$median_confidence_stats$Sepal.Length ## A crossed design: one panel per feature, the sexes along the x axis and ## the treatment the colours, so an effect that flips with sex is local. sim <- simulate_factorial_groups(n_feats = 6, n_per_cell = 8, seed = 1) cells <- draw_grouped_boxplot( data = sim$args$data, feats = sim$args$feats, factors = sim$args$factors, factor_lv = sim$args$factor_lv, ylab = "log2 abundance" ) ## One column per cell, labelled the way the answer key labels them. cells$box_summary_stats$prot_1 ## The transpose: one panel per sex, every feature together inside it. draw_grouped_boxplot( data = sim$args$data, feats = sim$args$feats, factors = sim$args$factors, factor_lv = sim$args$factor_lv, panel_by = "factor", ylab = "log2 abundance" ) ## `control_label` draws the named level first in its own factor, and takes ## the same argument the comparison does, so the two agree on where the ## reference cell sits. draw_grouped_boxplot( data = sim$args$data, feats = sim$args$feats[1:2], factors = sim$args$factors, control_label = list(sex = "female"), ylab = "log2 abundance" )
Draws one cell per feature and sample, with the sample groups as a coloured
annotation strip above the columns and a dendrogram on each axis that was
clustered. The input is the wide format the comparison functions take, one row
per observation and one column per feature, and it is transposed so that
features run down the rows the way an expression heatmap is usually read.
stats::heatmap() draws the cells, the strip and the trees; this function
decides what goes into them and adds the colour key beside them.
draw_heatmap( data, group = NULL, group_lv = NULL, feats = NULL, scale = c("feature", "sample", "none"), zlim = NULL, dist_method = c("euclidean", "correlation", "manhattan"), hclust_method = c("average", "complete", "ward.D2"), cluster_feats = TRUE, cluster_samples = TRUE, feat_labels = NULL, sample_labels = NULL, show_feat_names = TRUE, show_sample_names = TRUE, anno = FALSE, cex.anno = 1, n_colors = 101, main = NULL, cex.axis = 0.9, cex.main = 1.5, cex.legend = 1.2 )draw_heatmap( data, group = NULL, group_lv = NULL, feats = NULL, scale = c("feature", "sample", "none"), zlim = NULL, dist_method = c("euclidean", "correlation", "manhattan"), hclust_method = c("average", "complete", "ward.D2"), cluster_feats = TRUE, cluster_samples = TRUE, feat_labels = NULL, sample_labels = NULL, show_feat_names = TRUE, show_sample_names = TRUE, anno = FALSE, cex.anno = 1, n_colors = 101, main = NULL, cex.axis = 0.9, cex.main = 1.5, cex.legend = 1.2 )
data |
A data.frame or a matrix in wide format, one row per observation
and one column per feature. This is the same layout
|
group |
Grouping vector with one entry per row of |
group_lv |
Character vector of group levels, in the order they should
appear in the legend, or |
feats |
Character vector of numeric column names to draw, or |
scale |
How to put the features on a comparable scale before drawing:
|
zlim |
Numeric length-2 range the colours span, or |
dist_method |
Distance behind the clustering. |
hclust_method |
Linkage handed to |
cluster_feats, cluster_samples
|
Whether to cluster and reorder that
axis. |
feat_labels, sample_labels
|
Labels to draw in place of the column names
of |
show_feat_names, show_sample_names
|
Whether to draw those labels. The labels still exist when they are not drawn, so a hidden axis does not change anything else about the plot. |
anno |
If |
cex.anno |
Character expansion for those cell labels, relative to the smaller of the feature and sample axis label sizes. |
n_colors |
Number of colours in the blue-white-red ramp. |
main |
Plot title. |
cex.axis, cex.main, cex.legend
|
Character expansion for the axis labels, the title and the group legend. |
Features are z-scored across the samples by default. Without it a single
high-abundance feature takes the whole colour range and everything else is
left white, since the colour scale is shared by every cell in the plot and
features are not measured on a common scale. The plot does not say which
scale ran: naming it would take a line of text wider than the key it titles,
and the numbers beside the key are the scaled values either way. A
feature with no variance would divide by zero, so it is only centred and ends
up flat at the middle of the scale; how many were is reported in a message.
A diverging palette needs a meaningful midpoint. When zlim is not given,
zero is that midpoint if the values being drawn have both signs, which is
always the case after z-scoring, and the range is made symmetric around it.
Values of one sign only have no such point, so the range is the range of the
values and white falls in the middle of it.
Missing values are drawn as grey cells rather than dropped, so a gap is
visible as a gap. Clustering leaves them to stats::dist(), which measures a
pair on the values they share. A pair that shares none has no distance at
all, and rather than fail, that axis is left in its input order with a
message saying so.
The clustering is done here rather than left to stats::heatmap(), which
would standardise after clustering and divide a flat feature by zero, and the
trees are handed over already built. What comes back is therefore the tree the
plot shows. The panel proportions are stats::heatmap()'s, including the
square cells its respect = TRUE layout asks for. That layout keeps the block
of panels square and centres it, so a device much wider than it is tall is left
with empty space to the right of the plot and its key.
stats::heatmap() draws no colour key, so this function keeps a strip beside
the plot for one and overlays it after: an upright bar with its numbers to the
right of it, and the group legend to the right of those, both starting level
with the top row of cells. The strip is as wide as those need rather than a
fixed fraction of the device, and it is placed against the feature labels
rather than at the edge, since the space the centred layout leaves over would
otherwise read as a gap between the plot and its key. The panel layout, the
margins and the plot region are all restored on exit, so the caller's device
is left as it was found.
A list, invisibly:
matrixThe scaled matrix as it was drawn: features in rows,
samples in columns, both in the order they appear on the plot, and the
row and column names as they were labelled. Values are not clamped to
zlim, which is a decision about colour rather than about the data.
feat_order,sample_order
The permutations the clustering chose,
as indices into feats and into the rows of data that were kept.
feat_hclust,sample_hclust
The stats::hclust() objects behind
the dendrograms, or NULL for an axis that was not clustered.
zlimThe colour range, derived or as supplied.
group_colorsThe colour drawn for each group level, or NULL
when no group was supplied.
draw_grouped_boxplot(), which takes the same wide input.
## Features are z-scored, so the two planted directions separate by colour ## and the clustering of the columns finds the two groups on its own. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) out <- draw_heatmap( data = as.matrix(sim$args$data), group = sim$args$group, group_lv = sim$args$group_lv, show_sample_names = FALSE, main = "30 features, 10 of them planted" ) ## The clustering is on the result, so what the picture shows can be checked ## rather than eyeballed. head(rownames(out$matrix)) out$sample_hclust$method ## Only the planted features, in the order they were named draw_heatmap( data = sim$args$data, group = sim$args$group, group_lv = sim$args$group_lv, feats = sim$truth$features[sim$truth$direction != "none"], cluster_feats = FALSE, show_sample_names = FALSE ) ## Unscaled values, with the four measurements on their own scale draw_heatmap( data = iris[1:4], group = iris$Species, group_lv = levels(iris$Species), scale = "none", show_sample_names = FALSE, main = "iris, cm" ) ## No group strip or group legend when both are omitted draw_heatmap( data = as.matrix(sim$args$data), show_sample_names = FALSE )## Features are z-scored, so the two planted directions separate by colour ## and the clustering of the columns finds the two groups on its own. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) out <- draw_heatmap( data = as.matrix(sim$args$data), group = sim$args$group, group_lv = sim$args$group_lv, show_sample_names = FALSE, main = "30 features, 10 of them planted" ) ## The clustering is on the result, so what the picture shows can be checked ## rather than eyeballed. head(rownames(out$matrix)) out$sample_hclust$method ## Only the planted features, in the order they were named draw_heatmap( data = sim$args$data, group = sim$args$group, group_lv = sim$args$group_lv, feats = sim$truth$features[sim$truth$direction != "none"], cluster_feats = FALSE, show_sample_names = FALSE ) ## Unscaled values, with the four measurements on their own scale draw_heatmap( data = iris[1:4], group = iris$Species, group_lv = levels(iris$Species), scale = "none", show_sample_names = FALSE, main = "iris, cm" ) ## No group strip or group legend when both are omitted draw_heatmap( data = as.matrix(sim$args$data), show_sample_names = FALSE )
Joins the cell means of one factor across the levels of another, one line per level of the tracing factor, which is the picture an interaction term is a test of: parallel lines are additive factors and crossing or fanning lines are the interaction the term reports a p-value for.
draw_interaction_plot( comparison_result, x = NULL, trace = NULL, facet = NULL, type = c("auto", "pairwise", "matrix", "facet"), feats = NULL, errorbar = c("none", "se", "ci"), panel_nrow = NULL, dark = FALSE, ylim = NULL, xlab = NULL, ylab = NULL, main = NULL, col = NULL, lwd = 2, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1 )draw_interaction_plot( comparison_result, x = NULL, trace = NULL, facet = NULL, type = c("auto", "pairwise", "matrix", "facet"), feats = NULL, errorbar = c("none", "se", "ci"), panel_nrow = NULL, dark = FALSE, ylim = NULL, xlab = NULL, ylab = NULL, main = NULL, col = NULL, lwd = 2, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1 )
comparison_result |
A factorial comparison result, as returned by
|
x |
Name of the factor on the x axis. |
trace |
Name of the factor one line is drawn per. Unnamed, the factors
are taken in declaration order, |
facet |
Name of the factor whose levels are kept in panels of their own
instead of averaged away. Required by |
type |
|
feats |
Character vector of features to draw, in panel order. |
errorbar |
|
panel_nrow |
Number of rows to arrange the panels in, or |
dark |
If |
ylim |
Numeric length-2 y axis range, or |
xlab, ylab, main
|
Axis and title labels. All three are derived from the
result when left |
col |
Colours for the levels of the tracing factor, recycled if short.
|
lwd |
Line width of the traces. |
cex.axis, cex.lab, cex.main, cex.legend
|
Character expansion for the axis annotation, the axis label, the title and the legend. |
The means come from comparison_result$cells, so the plot and the F tests
beside it describe the same fit rather than two passes over the data. Where
the design holds more factors than the two being drawn, the remaining ones are
averaged away, unweighted, which is the same marginal mean the post-hoc stage
contrasts: the gap between two points of one line is the estimate its
contrast reports in comparison_result$posthoc.
Three views are available:
"pairwise"One pair of factors, trace against x, with every
other factor averaged away. One panel per feature, so several features can
be read side by side.
"matrix"Every pair of factors at once, in an upper triangle: the row is the tracing factor and the column the one on the x axis. One feature only, since the panels are spent on the factors.
"facet"trace against x again, but with the levels of facet
kept apart in panels of their own rather than averaged away, which is what
shows a two-factor interaction that itself depends on a third factor. One
feature only, for the same reason.
type = "auto", the default, reads the arguments: naming facet asks for the
facet view, naming x or trace for the pairwise one, and naming none of
them gives the pairwise view for two factors and the matrix for three or more.
The function changes graphical parameters and the panel layout, and restores both on exit, so the caller's device is left as it was found.
Each level of the tracing factor gets a colour, a plotting symbol and a line type of its own, so the lines stay apart in greyscale and for a reader who cannot tell the two colours apart.
The plotted means, invisibly, one row per point in the order they were
drawn, with columns panel, features, x_factor, x_level,
trace_factor, trace_level, n_cells, mean, se, lower_conf and
upper_conf. n_cells is how many cells were averaged into the point, so
1 marks a cell mean and anything more a marginal one. The view that
type = "auto" resolved to is attached as the attribute "view".
The bars are built from cells$se, which is sqrt(ms_error / n) pooled over
the whole model, so a bar on a marginal mean over a set of cells has half-width
sqrt(sum(se^2) / length(se)^2) times one or the qt() multiplier. That is
the standard error of the mean being drawn, not of the difference between two
of them, and the difference is what the interaction is about. Two bars that
overlap do not settle the term test, which is in $terms, and two points whose
gap you want tested are in $posthoc.
compare_factorial_groups() for the result this reads,
draw_grouped_boxplot() for the observations behind the means, and
draw_volcano_plot() with estimate_significance(by = "term") for which
features have an interaction at all.
sim <- simulate_factorial_groups(n_feats = 4, n_per_cell = 8, seed = 1) res <- do.call(compare_factorial_groups, c(sim$args, list(diagnose = FALSE))) ## One panel per feature, the first factor tracing the second draw_interaction_plot(res) ## The same two factors the other way round, with standard error bars draw_interaction_plot(res, x = "treatment", trace = "sex", errorbar = "se") ## Two features only, stacked draw_interaction_plot(res, feats = res$features[1:2], panel_nrow = 2) ## Every pair of factors of a three-factor design, for one feature sim3 <- simulate_factorial_groups( n_feats = 2, n_per_cell = 8, seed = 2, factor_lv = list(treatment = c("control", "treat_A"), sex = c("male", "female"), site = c("north", "south", "east")) ) res3 <- do.call(compare_factorial_groups, c(sim3$args, list(diagnose = FALSE))) draw_interaction_plot(res3, type = "matrix") ## The third factor kept apart in panels instead of averaged away draw_interaction_plot(res3, x = "site", trace = "treatment", facet = "sex", errorbar = "ci")sim <- simulate_factorial_groups(n_feats = 4, n_per_cell = 8, seed = 1) res <- do.call(compare_factorial_groups, c(sim$args, list(diagnose = FALSE))) ## One panel per feature, the first factor tracing the second draw_interaction_plot(res) ## The same two factors the other way round, with standard error bars draw_interaction_plot(res, x = "treatment", trace = "sex", errorbar = "se") ## Two features only, stacked draw_interaction_plot(res, feats = res$features[1:2], panel_nrow = 2) ## Every pair of factors of a three-factor design, for one feature sim3 <- simulate_factorial_groups( n_feats = 2, n_per_cell = 8, seed = 2, factor_lv = list(treatment = c("control", "treat_A"), sex = c("male", "female"), site = c("north", "south", "east")) ) res3 <- do.call(compare_factorial_groups, c(sim3$args, list(diagnose = FALSE))) draw_interaction_plot(res3, type = "matrix") ## The third factor kept apart in panels instead of averaged away draw_interaction_plot(res3, x = "site", trace = "treatment", facet = "sex", errorbar = "ci")
Splits the x axis by the first variable's marginal shares and each strip by the second variable's conditional shares, so the area of a tile is the cell's share of the table. Three things are drawn on top of that geometry, and each of them answers a question the geometry alone leaves open.
draw_mosaic_plot( categorical_comparison_result, shade = TRUE, residual = c("pearson", "standardized"), expected_line = TRUE, anno_cells = c("auto", "count", "percent", "both", "none"), gap = 0.015, xlab = NULL, ylab = NULL, main = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, cex.legend = 1.1, cex.anno = 1, dark = FALSE ) ## S3 method for class 'sa_categorical' plot(x, ...)draw_mosaic_plot( categorical_comparison_result, shade = TRUE, residual = c("pearson", "standardized"), expected_line = TRUE, anno_cells = c("auto", "count", "percent", "both", "none"), gap = 0.015, xlab = NULL, ylab = NULL, main = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, cex.legend = 1.1, cex.anno = 1, dark = FALSE ) ## S3 method for class 'sa_categorical' plot(x, ...)
categorical_comparison_result |
A categorical comparison result, as
returned by |
shade |
Logical. If |
residual |
Which residual the shading reads. |
expected_line |
Logical. If |
anno_cells |
What to write on a tile. |
gap |
Gap between neighbouring tiles, as a fraction of the axis, for each gap rather than for all of them together. Capped so that the gaps never take more than two fifths of an axis however many levels there are. |
xlab, ylab, main
|
Axis labels and title. |
cex.lab, cex.axis, cex.main, cex.legend, cex.anno
|
Character expansion for the axis labels, the level names, the title, the residual key and the tile annotation. |
dark |
Logical. If |
x |
A categorical comparison result, as returned by
|
... |
Arguments passed on to |
The shading says which cells made the statistic what it is. It reads the
Pearson residual, the quantity that squares and sums to that statistic, at the
conventional cuts of 2 and 4. The two colours are the ones
draw_volcano_plot() already uses for a feature that moved up and one that
moved down, so "more than expected" and "less than expected" are the same pair
of colours the rest of the package reads as a direction.
The expected line says what "expected" was. A dashed segment sits at each boundary the tiles of a strip would have had under the null hypothesis, so the departure is the distance between a tile edge and the line beside it rather than something to be inferred by comparing strips by eye. Under independence the lines fall at the same heights in every strip, which is what makes an association visible at a glance; under symmetry they do not, because there the expectation is a cell against its own transpose.
The annotation says how many observations a tile stands for, which area cannot: a wide short tile and a narrow tall one can hold the same count.
The level names on the y axis are read off the reference strip, the first
one, which the control_label and category_lv of
compare_categorical_groups() decide. No single set
of positions can label every strip, since the whole content of a mosaic is that
the strips are cut at different heights, so labelling one of them and saying
which is the honest version of the choice. The first strip is the one the rest
of the package already treats as the reference.
Only the par() values this function sets are put back. A blanket
par(no.readonly = TRUE) snapshot also carries fin, pin and mai, which
are absolute sizes, and restoring them pins the next plot to the size this
one happened to be drawn at.
Invisibly, a list of the picture as it was drawn.
cellsThe cell table with x1, x2, y1, y2 and fill added,
in the order the tiles were painted.
widthsMarginal share of each strip, named by row level. These are the widths before the gaps are taken out of them.
heightsConditional share of each tile within its strip, as a matrix of row level by column level.
expected_propThe same shares the null hypothesis expects, which is what the dashed segments were drawn from.
empty_levelsrow and col levels that hold no observation, so
drew no tile and took no axis label.
nullWhich hypothesis the shading and the segments are about.
residual,residual_breaks,colors
The scale the shading read.
The one the result was tested against,
categorical_comparison_result$design$null. That is the whole point of
reading it off the result rather than recomputing it here: a matched design
is tested for symmetry, so a mosaic of it shaded by departure from
independence would be a picture of a hypothesis nothing in the result has a
p-value for. A bare table() carries no such hypothesis, which is why one is
not accepted here: compare_categorical_groups() is what settles the null,
the levels and their order, and this function draws what it settled.
residual = "standardized" is refused under symmetry. The variance correction
that residual divides by is derived for a table held against its own margins
and $cells$std_residual is NA there, so the request has no answer rather
than a different one.
compare_categorical_groups() for the analysis this draws, and
draw_grouped_boxplot() for the numeric counterpart.
smoking <- data.frame( smoker = rep(c("y", "n"), each = 60), grade = c(rep(c("high", "mid", "low"), c(10, 20, 30)), rep(c("high", "mid", "low"), c(30, 20, 10))) ) res <- compare_categorical_groups(smoking) draw_mosaic_plot(res) ## The dashed segments are what independence expected, so the departure is a ## distance rather than a comparison between strips. drawn <- draw_mosaic_plot(res, anno_cells = "both") drawn$expected_prop ## The levels that take part and the order they sit in are settled by the ## comparison, so a different reference is a different call to it. draw_mosaic_plot( compare_categorical_groups(smoking, control_label = c(smoker = "y")) ) ## A matched design is shaded by departure from symmetry, so the diagonal is ## neutral by construction and only the discordant cells carry colour. before_after <- data.frame( before = rep(c("pass", "fail"), c(20, 30)), after = c(rep(c("pass", "fail"), c(18, 2)), rep(c("pass", "fail"), c(14, 16))) ) draw_mosaic_plot(compare_categorical_groups(before_after, paired = TRUE))smoking <- data.frame( smoker = rep(c("y", "n"), each = 60), grade = c(rep(c("high", "mid", "low"), c(10, 20, 30)), rep(c("high", "mid", "low"), c(30, 20, 10))) ) res <- compare_categorical_groups(smoking) draw_mosaic_plot(res) ## The dashed segments are what independence expected, so the departure is a ## distance rather than a comparison between strips. drawn <- draw_mosaic_plot(res, anno_cells = "both") drawn$expected_prop ## The levels that take part and the order they sit in are settled by the ## comparison, so a different reference is a different call to it. draw_mosaic_plot( compare_categorical_groups(smoking, control_label = c(smoker = "y")) ) ## A matched design is shaded by departure from symmetry, so the diagonal is ## neutral by construction and only the discordant cells carry colour. before_after <- data.frame( before = rep(c("pass", "fail"), c(20, 30)), after = c(rep(c("pass", "fail"), c(18, 2)), rep(c("pass", "fail"), c(14, 16))) ) draw_mosaic_plot(compare_categorical_groups(before_after, paired = TRUE))
The picture of an evaluate_regression_models() result: each model's
predictions against the outcome they were predicting, with the identity line
to read them against and the calibration line to read the identity against.
draw_prediction_plot( performance_result, models = NULL, type = c("auto", "overlay", "panel"), panel_nrow = NULL, points = TRUE, anno_corr = FALSE, anno_rsq = FALSE, anno_lm = FALSE, dark = FALSE, lim = NULL, col = NULL, lwd = 2, xlab = NULL, ylab = NULL, main = NULL, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1, cex.anno = NULL )draw_prediction_plot( performance_result, models = NULL, type = c("auto", "overlay", "panel"), panel_nrow = NULL, points = TRUE, anno_corr = FALSE, anno_rsq = FALSE, anno_lm = FALSE, dark = FALSE, lim = NULL, col = NULL, lwd = 2, xlab = NULL, ylab = NULL, main = NULL, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1, cex.anno = NULL )
performance_result |
A regression evaluation, as returned by
|
models |
Which models to draw and in what order, or |
type |
|
panel_nrow |
Rows of panels under |
points |
Whether to draw the predictions themselves. |
anno_corr |
Whether to report each model's correlation beside its name. |
anno_rsq |
Whether to report each model's held-out R-squared from
|
anno_lm |
Whether to report each model's calibration line as an equation. |
dark |
Whether to draw on a dark background. |
lim |
Range of both axes, or |
col |
One colour, or one per drawn model. |
lwd |
Width of the calibration lines. |
xlab, ylab, main
|
Axis and figure labels. |
cex.axis, cex.lab, cex.main, cex.legend, cex.anno
|
Relative text sizes.
|
Two lines are drawn in every panel. The dotted grey one is the identity,
where a prediction that was exactly right would lie. The solid coloured one
is lm(predicted ~ observed), taken from the calib_slope and
calib_intercept of $metrics rather than fitted again here, so the picture
and the table cannot drift apart. A slope under one is a model whose
predictions are squeezed towards their own mean, which is the usual shape of
a fit scored on rows it has not seen.
type decides how more than one model is shown. "panel" gives each its own
square, which is the default past one model because two clouds of points on
shared axes make a third cloud that belongs to neither. "overlay" puts them
all in one square, which is readable with points = FALSE, where what is
compared is the calibration lines. "auto" is "overlay" for a single model
and "panel" beyond that.
Both axes span the same range in every panel, taken over every model drawn, so the panels are comparable and the identity line is the diagonal of the square rather than an arbitrary chord.
The rows of $metrics that were drawn, invisibly, carrying the
resolved type as a "view" attribute.
evaluate_regression_models() for the result this draws, and
draw_roc_curve() for the classification counterpart.
train <- mtcars[1:24, ] test <- mtcars[25:32, ] full <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) small <- fit_linear_regression(train, outcome = "mpg", predictors = "wt", cv = FALSE) res <- evaluate_regression_models(full, list(weight_only = small), newdata = test) draw_prediction_plot(res, anno_lm = TRUE) draw_prediction_plot(res, type = "overlay", points = FALSE, anno_corr = TRUE, anno_rsq = TRUE)train <- mtcars[1:24, ] test <- mtcars[25:32, ] full <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) small <- fit_linear_regression(train, outcome = "mpg", predictors = "wt", cv = FALSE) res <- evaluate_regression_models(full, list(weight_only = small), newdata = test) draw_prediction_plot(res, anno_lm = TRUE) draw_prediction_plot(res, type = "overlay", points = FALSE, anno_corr = TRUE, anno_rsq = TRUE)
The picture of an evaluate_classification_models() result: one curve per
model, all on the rows every model was scored on, with the chance diagonal to
read them against.
draw_roc_curve( performance_result, models = NULL, anno_auc = FALSE, chance = TRUE, dark = FALSE, col = NULL, lwd = 2, lty = 1, legend_pos = "bottomright", xlab = NULL, ylab = NULL, main = NULL, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1, cex.anno = NULL )draw_roc_curve( performance_result, models = NULL, anno_auc = FALSE, chance = TRUE, dark = FALSE, col = NULL, lwd = 2, lty = 1, legend_pos = "bottomright", xlab = NULL, ylab = NULL, main = NULL, cex.axis = 1.2, cex.lab = 1.3, cex.main = 1.3, cex.legend = 1.1, cex.anno = NULL )
performance_result |
A classification evaluation, as returned by
|
models |
Which models to draw and in what order, or |
anno_auc |
Whether to add each model's AUC to its legend entry. |
chance |
Whether to draw the chance diagonal. |
dark |
Whether to draw on a dark background. |
col |
One colour, or one per drawn model. |
lwd |
Width of the curves. |
lty |
One line type, or one per drawn model. |
legend_pos |
Where to put the legend, or |
xlab, ylab, main
|
Axis and figure labels. |
cex.axis, cex.lab, cex.main, cex.legend, cex.anno
|
Relative text sizes.
|
The curves are $curves, the operating points the evaluation already
computed, rather than anything recomputed here, so the picture and the auc
column of $metrics describe the same curve. Consecutive points are joined
by straight lines, which is what makes the area under the drawn curve the
auc beside it: a run of tied predictions cannot be separated by any
threshold and the curve crosses it diagonally.
Every model is drawn against the same rows, since that is what the evaluation scored them on, so two curves crossing is a statement about the models rather than about which rows each of them managed.
The rows of $metrics that were drawn, invisibly.
evaluate_classification_models() for the result this draws, and
draw_prediction_plot() for the regression counterpart.
iris2 <- iris[iris$Species != "setosa", ] iris2$Species <- factor(iris2$Species) train <- iris2[c(1:35, 51:85), ] test <- iris2[c(36:50, 86:100), ] full <- fit_logistic_regression(train, outcome = "Species", cv = FALSE) petal <- fit_logistic_regression(train, outcome = "Species", predictors = "Petal.Width", cv = FALSE) res <- evaluate_classification_models(full, list(petal_only = petal), newdata = test) draw_roc_curve(res, anno_auc = TRUE)iris2 <- iris[iris$Species != "setosa", ] iris2$Species <- factor(iris2$Species) train <- iris2[c(1:35, 51:85), ] test <- iris2[c(36:50, 86:100), ] full <- fit_logistic_regression(train, outcome = "Species", cv = FALSE) petal <- fit_logistic_regression(train, outcome = "Species", predictors = "Petal.Width", cv = FALSE) res <- evaluate_classification_models(full, list(petal_only = petal), newdata = test) draw_roc_curve(res, anno_auc = TRUE)
Plots the effect size against -log10(pvalue) for the table
estimate_significance() returns, colours the features that clear both
cutoffs and labels the strongest of them. Under an omnibus or contrast
reading the effect is signed log2fc and points are coloured by direction.
A term reading of a factorial comparison is drawn as one panel per term, with
|log2_effect| on the x axis and a single significance colour.
draw_volcano_plot( significance_result, terms = NULL, panel_nrow = NULL, use_adjusted = TRUE, log2fc_cutoff = NULL, pval_cutoff = NULL, anno_feats = TRUE, anno_top = 10, cex.anno = 1, xlim = NULL, ylim = NULL, xlab = NULL, main = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, margin = c(5, 5, 4, 3), ... )draw_volcano_plot( significance_result, terms = NULL, panel_nrow = NULL, use_adjusted = TRUE, log2fc_cutoff = NULL, pval_cutoff = NULL, anno_feats = TRUE, anno_top = 10, cex.anno = 1, xlim = NULL, ylim = NULL, xlab = NULL, main = NULL, cex.lab = 1.3, cex.axis = 1.2, cex.main = 1.3, margin = c(5, 5, 4, 3), ... )
significance_result |
The object returned by
|
terms |
Which terms get a panel, read only under a term reading. |
panel_nrow |
How many rows the panels are laid out in, or |
use_adjusted |
Logical. If |
log2fc_cutoff, pval_cutoff
|
Cutoffs for calling a feature changed and
significant, drawn as guides. |
anno_feats |
Logical. If |
anno_top |
How many features to label in each direction under a signed
reading (up to |
cex.anno |
Character expansion for those labels. |
xlim, ylim
|
Numeric length-2 axis ranges, or |
xlab |
X axis label, or |
main |
Plot title. With panels it is the title of the figure, written once above them, and each panel is titled with its term. |
cex.lab, cex.axis, cex.main
|
Character expansion for the axis labels, the axis annotation and the title. |
margin |
Plot margins in lines, passed to |
... |
Additional arguments passed to |
The plot margins are restored on exit, but the coordinate system is
deliberately left in place, so graphics::points(), graphics::text() and
friends can still be used to add to the finished plot. With panels what is
left in place is the last panel's coordinate system, and the panel grid itself
is undone, mfrow included, since a figure of several plots is finished when
the last of them is drawn.
A p-value of exactly zero has no finite -log10(). Rather than dropping the
most significant points or letting an infinite axis limit blank the plot, the
axis is scaled to the largest finite value and those points are drawn at the
top of it. Non-finite log2fc, which compare_two_groups() produces when a
group centre is zero, is capped at the edge of the x axis the same way. Either
kind of capping is reported in a message(), since a capped point no longer
sits at its true coordinate.
What the x axis compares is not the same question in every scenario, so the
label follows the comparison the verdict came from. A multi-group omnibus
verdict carries the single fold change its effect table holds, the level
furthest from the reference rather than any named pair, and the label says so.
A factorial omnibus verdict does the same with cells: the reference cell is
where every factor sits at its first level, and the label names it. A term
table carries an ANOVA component rather than a ratio of two centres, so the
panel plots |log2_effect| and is labelled as an absolute effect rather than
a fold change; see "The size of a term" in compare_factorial_groups() for
how to read its magnitude. Every other reading compares two fixed centres and
is labelled log2 FC. Pass xlab to override this.
Points are coloured by the same masks that select the labels, so which
features are highlighted and which are labelled can never disagree. With the
default arguments those masks reproduce the is_signif column of the input.
Signed readings colour up and down separately; a term panel draws significant
points in black because the axis has no direction, and keeps labels red so
they stay legible without being read as up-regulation. The labels are drawn
in a brighter shade than the points on purpose, so that a label stays
legible where it overlaps them.
NULL invisibly.
A term reading is drawn as a figure rather than asked to name one table, unlike a contrast reading. A crossed design decomposes into a fixed and small set of terms, three for two factors, and the whole point of the reading is to see which of them a feature responded to, which is a comparison between the panels. The number of pairwise contrasts, by contrast, follows from the level counts and is arbitrary.
Every panel is judged by one rule, the cutoffs of the first table, and shares
the axes with the others unless xlim or ylim says otherwise. Both are what
makes a point in one panel comparable with a point in another. The shared x
axis starts at zero and the magnitude cutoff is drawn as a single vertical
guide, because the plotted quantity is |log2_effect| rather than a signed
fold change. Significant points are black; labels stay red so they are not
read as up-regulation.
estimate_significance(), whose output is the only argument this
function needs.
iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups( data = iris2, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris2$Species, group_lv = c("virginica", "versicolor") ) sig <- estimate_significance(res, log2fc_cutoff = 0.1) draw_volcano_plot(sig, main = "virginica vs versicolor") ## Unadjusted p-values, no labels draw_volcano_plot(sig, use_adjusted = FALSE, anno_feats = FALSE) ## A cutoff other than the one the verdict used draw_volcano_plot(sig, log2fc_cutoff = 0.3) ## A multi-group verdict says on the x axis which levels its log2fc compares multi <- compare_multiple_groups(iris, c("Sepal.Length", "Petal.Length"), iris$Species, levels(iris$Species)) draw_volcano_plot(estimate_significance(multi, log2fc_cutoff = 0.1)) ## A factorial omnibus verdict names the reference cell on the x axis fact <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension"), posthoc = FALSE ) draw_volcano_plot(estimate_significance(fact, log2fc_cutoff = 0.1), main = "warpbreaks") ## One panel per term of a crossed design: wool, tension, wool:tension draw_volcano_plot(estimate_significance(fact, by = "term", log2fc_cutoff = 0.1), main = "warpbreaks, by term")iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups( data = iris2, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris2$Species, group_lv = c("virginica", "versicolor") ) sig <- estimate_significance(res, log2fc_cutoff = 0.1) draw_volcano_plot(sig, main = "virginica vs versicolor") ## Unadjusted p-values, no labels draw_volcano_plot(sig, use_adjusted = FALSE, anno_feats = FALSE) ## A cutoff other than the one the verdict used draw_volcano_plot(sig, log2fc_cutoff = 0.3) ## A multi-group verdict says on the x axis which levels its log2fc compares multi <- compare_multiple_groups(iris, c("Sepal.Length", "Petal.Length"), iris$Species, levels(iris$Species)) draw_volcano_plot(estimate_significance(multi, log2fc_cutoff = 0.1)) ## A factorial omnibus verdict names the reference cell on the x axis fact <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension"), posthoc = FALSE ) draw_volcano_plot(estimate_significance(fact, log2fc_cutoff = 0.1), main = "warpbreaks") ## One panel per term of a crossed design: wool, tension, wool:tension draw_volcano_plot(estimate_significance(fact, by = "term", log2fc_cutoff = 0.1), main = "warpbreaks, by term")
The categorical counterpart of estimate_significance(), and the reason it is
a second function rather than a branch of that one is that the two axes a
verdict is made of sit at a different granularity here. A comparison asks its
question once per feature; a contingency table is asked about as a whole, and
the place where a signed effect and a p-value exist side by side is one level
down, at the cell.
estimate_categorical_significance( categorical_comparison_result, by = c("cell", "table"), test = names(categorical_comparison_result$tests)[1], log2_lift_cutoff = 1, pval_cutoff = 0.05, adj_type = "BH", measure = "auto", effect_cutoff = NULL )estimate_categorical_significance( categorical_comparison_result, by = c("cell", "table"), test = names(categorical_comparison_result$tests)[1], log2_lift_cutoff = 1, pval_cutoff = 0.05, adj_type = "BH", measure = "auto", effect_cutoff = NULL )
categorical_comparison_result |
A categorical comparison result, as
returned by |
by |
|
test |
Which test supplies the p-value of a table reading. One of
|
log2_lift_cutoff |
Minimum |
pval_cutoff |
Largest p-value allowed for a verdict of significant. Read
by both readings, against |
adj_type |
Multiplicity adjustment across the cells, one of
stats::p.adjust.methods. Read only by |
measure |
Which row of |
effect_cutoff |
Magnitude |
is_signif is three-valued and follows the rule the numeric scenarios use.
NA is undecided rather than decided against, which is what an NA
std_residual or an undefined lift leaves a cell as.
A cell holding no observation at all has a lift of exactly zero and so a
log2_lift of -Inf, an infinitely large shortfall, which clears any
magnitude cutoff. A cell whose expected count is zero has no ratio to take and
is NA in both columns. The two are different findings and are reported
differently, the same way estimate_significance() separates a fold change of
zero from one that cannot be formed.
An sa_categorical_significance object, a list of two elements:
analysis_type"categorical_comparison".
significanceWith by = "cell", a data.frame with one row per
cell and the columns row_level, col_level, observed, expected,
lift, log2_lift, std_residual, pvalue, adj_pvalue and
is_signif. With by = "table", a one-row data.frame with measure,
estimate, lower_conf, upper_conf, pvalue and is_signif.
The cutoffs, the reading, the null hypothesis and whichever of the test name and the adjustment applies are attached to the data.frame as attributes, so a table that has been passed around still says which rule produced it.
A cell table keys on c("row_level", "col_level"), the key
categorical_comparison_result$cells and
simulate_categorical_groups()$truth_cell also use, so a verdict merges with
either without renaming. Note that truth_cell carries a lift column of
its own, the planted one, so a merge of the two distinguishes them as
lift.x and lift.y.
This is deliberately not an sa_significance. Its columns are cells rather
than features and log2_lift rather than log2fc, so draw_volcano_plot()
refusing it is the point of the separate class rather than an omission.
draw_mosaic_plot() is what draws this scenario.
compare_categorical_groups() says that an association is not signed, which is
true of the table: cramers_v reports how far the table sits from its null
and not in which direction, because past a 2 x 2 there is no single direction
to name. A cell is different. It was expected at some count and observed at
another, and observed / expected says both how far it moved and which way.
That ratio is lift, the same quantity simulate_categorical_groups() plants
and reports in truth_cell$lift, and log2(lift) is what the effect axis of
this function is. It is defined on every table, 2 x 2, 2 x 3 or larger, so one
cutoff means the same thing whatever shape the table is.
The p-value axis is std_residual, which is built to be referred to a standard
normal, so 2 * pnorm(-abs(std_residual)) is the cell's own two-sided
p-value. This is the standard post-hoc reading of a contingency table.
The two axes are not the same information. lift is a ratio and does not
change when the table is observed on twice as many rows; std_residual grows
with the square root of the count. So a cell can be far from what was expected
and poorly evidenced, or close to it and firmly established, exactly as
log2fc and a p-value come apart in the numeric scenarios.
A cell axis also restores a multiplicity axis. The table has as many cells as
it has, they are one family, and adj_type adjusts across them. That is why
this function computes an adjustment rather than reusing one: sa_categorical
carries no pval_adj column, there being nothing to adjust across at the level
the tests are reported at.
by = "cell"One row per cell of the table, with log2_lift on the
effect axis and the adjusted p-value of the cell's standardized residual on
the other. This is the reading that generalises, so it is the default.
by = "table"One row, the whole-table verdict: an association
measure out of $association beside the p-value of one of the tests. There
is no multiplicity here, one table being one question, so no adj_pvalue
column is carried.
Each reading ignores the arguments the other one reads, and says so rather than letting a setting that changes nothing pass unremarked.
measure = "auto" reads the design, since which measures exist at all depends
on it and on the size of the table.
| design | table | measure |
| independent | 2 x 2 | odds_ratio |
| independent | larger | cramers_v |
| matched, two conditions | 2 x 2 | odds_ratio_paired |
| matched, three or more | condition by response | kendalls_w
|
Any other row of $association can be named instead.
effect_cutoff is NULL by default, and then the verdict is the p-value
alone. The conventional thresholds for cramers_v are not defaults here
because they are conventions rather than facts about the measure, and a
default is the one place a convention is hardest to notice. Naming a number
reads it on the measure's own scale: odds_ratio and odds_ratio_paired are
ratios centred at 1, so the cutoff is a fold either way and has to be at least
1, while every other measure is centred at zero and the cutoff is compared
against the magnitude.
A matched two-condition design is tested for symmetry, and the variance
correction the standardized residual divides by is derived for a table held
against its own margins. $cells$std_residual is therefore NA throughout
such a result, so there is no p-value axis to read and the request is refused
rather than answered with a different quantity. by = "table" reads it, and
draw_mosaic_plot() refuses residual = "standardized" there for the same
reason.
Three or more matched conditions are a different case. Their null is marginal homogeneity, read off a condition-by-response table whose arithmetic is that of independence, so the standardized residual exists and the cell reading works.
Haberman, S. J. (1973). The analysis of residuals in cross-classified tables. Biometrics, 29(1), 205-220.
Beasley, T. M. and Schumacker, R. E. (1995). Multiple regression approach to analyzing contingency tables: Post hoc and planned comparison procedures. The Journal of Experimental Education, 64(1), 79-93.
Agresti, A. (2002). Categorical Data Analysis, 2nd ed. Wiley.
compare_categorical_groups() for the input,
draw_mosaic_plot() to draw the same residuals this reads, and
estimate_significance() for the numeric scenarios.
smoking <- data.frame( smoker = rep(c("y", "n"), each = 60), grade = c(rep(c("high", "mid", "low"), c(10, 20, 30)), rep(c("high", "mid", "low"), c(30, 20, 10))) ) res <- compare_categorical_groups(smoking) ## One verdict per cell. The default cutoff is a doubling either way, which on ## this table only the two corners reach. sig <- estimate_categorical_significance(res) sig sig$significance ## `lift` is what the effect axis is built from, and it is a ratio: the cell ## holding half of what independence expected is at 0.5. sig$significance[c("row_level", "col_level", "lift", "log2_lift")] ## A gentler magnitude cutoff asks less of a cell than a doubling. estimate_categorical_significance(res, log2_lift_cutoff = 0.5) ## The whole-table verdict instead. A 2 x 3 table has no odds ratio, so the ## measure `"auto"` reports is Cramer's V. estimate_categorical_significance(res, by = "table")$significance ## Two levels of `grade` make a 2 x 2 table, which is where an odds ratio ## exists, and `effect_cutoff` is then read as a fold either way. two_by_two <- compare_categorical_groups( smoking, category_lv = list(smoker = c("n", "y"), grade = c("low", "high")) ) estimate_categorical_significance(two_by_two, by = "table", effect_cutoff = 2)$significance ## The cell axis against the association that was planted on it. sim <- simulate_categorical_groups(n_samples = 400, assoc = 0.4, seed = 1) fit <- do.call(compare_categorical_groups, sim$args) scored <- merge(estimate_categorical_significance(fit)$significance, sim$truth_cell, by = c("row_level", "col_level")) scored[c("row_level", "col_level", "lift.x", "lift.y")]smoking <- data.frame( smoker = rep(c("y", "n"), each = 60), grade = c(rep(c("high", "mid", "low"), c(10, 20, 30)), rep(c("high", "mid", "low"), c(30, 20, 10))) ) res <- compare_categorical_groups(smoking) ## One verdict per cell. The default cutoff is a doubling either way, which on ## this table only the two corners reach. sig <- estimate_categorical_significance(res) sig sig$significance ## `lift` is what the effect axis is built from, and it is a ratio: the cell ## holding half of what independence expected is at 0.5. sig$significance[c("row_level", "col_level", "lift", "log2_lift")] ## A gentler magnitude cutoff asks less of a cell than a doubling. estimate_categorical_significance(res, log2_lift_cutoff = 0.5) ## The whole-table verdict instead. A 2 x 3 table has no odds ratio, so the ## measure `"auto"` reports is Cramer's V. estimate_categorical_significance(res, by = "table")$significance ## Two levels of `grade` make a 2 x 2 table, which is where an odds ratio ## exists, and `effect_cutoff` is then read as a fold either way. two_by_two <- compare_categorical_groups( smoking, category_lv = list(smoker = c("n", "y"), grade = c("low", "high")) ) estimate_categorical_significance(two_by_two, by = "table", effect_cutoff = 2)$significance ## The cell axis against the association that was planted on it. sim <- simulate_categorical_groups(n_samples = 400, assoc = 0.4, seed = 1) fit <- do.call(compare_categorical_groups, sim$args) scored <- merge(estimate_categorical_significance(fit)$significance, sim$truth_cell, by = c("row_level", "col_level")) scored[c("row_level", "col_level", "lift.x", "lift.y")]
Puts the two axes of a volcano plot side by side and flags the features that
clear both cutoffs. The effect size comes from the comparison's effect
table and the p-value from whichever test is named, so a single comparison can
be read out through the parametric, the rank-based or the robust lens without
recomputing anything.
estimate_significance( comparison_result, test = names(comparison_result$tests)[1], log2fc_cutoff = 1, pval_cutoff = 0.05, adj_type = NULL, by = c("omnibus", "contrast", "term") )estimate_significance( comparison_result, test = names(comparison_result$tests)[1], log2fc_cutoff = 1, pval_cutoff = 0.05, adj_type = NULL, by = c("omnibus", "contrast", "term") )
comparison_result |
A comparison result, as returned by
|
test |
Which test in |
log2fc_cutoff |
Minimum absolute effect size required to call a feature
significant. Under |
pval_cutoff |
Largest |
adj_type |
Multiplicity adjustment. |
by |
Which p-value the verdict is read from. |
is_signif combines the absolute effect size against log2fc_cutoff with
adj_pvalue <= pval_cutoff, and is therefore judged on the adjusted
p-values. Pass adj_type = "none" to test the raw ones. The effect column is
log2fc except under by = "term", where it is log2_effect.
The two ways of reading a multi-group comparison answer different questions
and use different numbers. by = "omnibus" asks whether a feature differs
across the levels at all, and pairs that with the one log2fc the effect
table carries, the most extreme level against the reference.
by = "contrast" asks the same question of one pair of levels at a time, and
each table carries the log2fc of that pair, in the direction its contrast
label reads. Both divide by the reference, so the two readings agree on which
way a feature moved.
The adjustment axis differs too. Under by = "contrast" with
adj_type = NULL the pval_adj of the pairwise stage is reused, which was
adjusted across the contrasts within each feature by posthoc_p_adjust, or
not at all for Tukey's HSD and Games-Howell, whose p-values are already
family-wise. Naming a method instead adjusts across the features within each
contrast, which is the axis by = "omnibus" always works on.
A factorial comparison has a third reading. The default "omnibus" pairs the
whole-model F test with the most extreme cell against the reference cell,
the combination where every factor sits at its first level. The table also
carries extreme_cell, naming which cell was furthest on the log2 scale.
That says a feature responded to the design and how far it moved at its
furthest point, but not which part of the design it responded to.
by = "term" answers that, one table per main effect and per interaction,
and needs no choice of adjustment axis:
both branches adjust across the features of one term, which is the family
$terms$pval_adj was built over as well.
A term table carries log2_effect, the same ANOVA component
$terms$log2_effect holds, rather than a ratio of two centres renamed to
log2fc. It measures a deviation from what the rest of the model predicts, so
a two-level factor whose levels differ by one log2 unit contributes -0.5 and
+0.5 rather than 1. The default log2fc_cutoff = 1 is therefore a stricter
demand here than the same number is elsewhere; halving it asks of a two-level
factor what the default asks of a fold change. See "The size of a term" in
compare_factorial_groups(). The column name matches the quantity so a term
volcano does not read as a fold-change plot; draw_volcano_plot() plots
|log2_effect| on that axis.
A feature whose omnibus test did not clear posthoc_alpha was never compared
pairwise, so its pvalue is NA in every contrast table. Its log2fc is
still reported, since a ratio of group centres does not depend on a test
having been run, and is_signif follows the same three-valued rule it does
everywhere else: NA, undecided, unless the magnitude cutoff already rules
the feature out, which makes it FALSE whatever the p-value would have been.
The two ways a fold change can fall outside the domain of log2() are not
equivalent. A fold change of exactly zero gives log2fc = -Inf, an
infinitely large decrease, which clears any magnitude cutoff. A fold change
whose two group centres have opposite signs gives log2fc = NaN, which makes
is_signif NA: such a feature is undecided rather than decided against.
compare_two_groups() reports both kinds when it builds the effect table.
Note that subset() and [ drop NA rows silently, so filter with
which(x$significance$is_signif) if the count matters.
An sa_significance object, a list of two elements:
analysis_typeThe analysis of the comparison this verdict was
read from, so a table that has been passed around still says which
scenario produced it.
significanceWith by = "omnibus", a data.frame with one row per
feature and the columns features, log2fc, pvalue, adj_pvalue and
is_signif. A multi-group omnibus table also carries extreme_level,
and a factorial one carries extreme_cell, naming the level or cell
whose centre produced log2fc. With by = "contrast", a list of those
same data.frames, one per pairwise contrast and named after it, in the
order comparison_result$pairwise[[test]] fixes. With by = "term",
one data.frame per model term named after it, in the order
comparison_result$terms lists them, with log2_effect in place of
log2fc.
The cutoffs, the test name and the adjustment actually used are attached to
each data.frame as attributes, which is where draw_volcano_plot() picks
them up so that the plotted guides cannot disagree with the verdict. A
contrast table carries contrast, group1 and group2 on top of those and
a term table carries term and term_order, so a single element of
significance can be handed straight to draw_volcano_plot() — and a whole
list of term tables can, which is how the term panels are drawn.
compare_two_groups() for the input and draw_volcano_plot() for
the output.
iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups( data = iris2, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris2$Species, group_lv = c("virginica", "versicolor") ) estimate_significance(res) ## The same comparison judged on the rank-based and the robust test estimate_significance(res, test = "wilcox_test", log2fc_cutoff = 0.1) estimate_significance(res, test = "robust_test", log2fc_cutoff = 0.1) ## A stricter fold change cutoff leaves nothing significant here, since the ## two species are well under two-fold apart on every measurement. estimate_significance(res, log2fc_cutoff = 1.5) ## Bonferroni instead of the Benjamini-Hochberg the comparison used estimate_significance(res, adj_type = "bonferroni", log2fc_cutoff = 0.1) ## One verdict table per pairwise contrast of a multi-group comparison multi <- compare_multiple_groups(iris, c("Sepal.Length", "Petal.Length"), iris$Species, levels(iris$Species)) by_pair <- estimate_significance(multi, by = "contrast", log2fc_cutoff = 0.1) names(by_pair$significance) by_pair$significance[["virginica - setosa"]] ## One verdict table per term of a crossed design fact <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension"), posthoc = FALSE ) draw_volcano_plot(estimate_significance(fact, log2fc_cutoff = 0.1)) by_term <- estimate_significance(fact, by = "term", log2fc_cutoff = 0.1) names(by_term$significance) by_term$significance[["wool:tension"]]iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups( data = iris2, feats = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), group = iris2$Species, group_lv = c("virginica", "versicolor") ) estimate_significance(res) ## The same comparison judged on the rank-based and the robust test estimate_significance(res, test = "wilcox_test", log2fc_cutoff = 0.1) estimate_significance(res, test = "robust_test", log2fc_cutoff = 0.1) ## A stricter fold change cutoff leaves nothing significant here, since the ## two species are well under two-fold apart on every measurement. estimate_significance(res, log2fc_cutoff = 1.5) ## Bonferroni instead of the Benjamini-Hochberg the comparison used estimate_significance(res, adj_type = "bonferroni", log2fc_cutoff = 0.1) ## One verdict table per pairwise contrast of a multi-group comparison multi <- compare_multiple_groups(iris, c("Sepal.Length", "Petal.Length"), iris$Species, levels(iris$Species)) by_pair <- estimate_significance(multi, by = "contrast", log2fc_cutoff = 0.1) names(by_pair$significance) by_pair$significance[["virginica - setosa"]] ## One verdict table per term of a crossed design fact <- compare_factorial_groups( data = warpbreaks, feats = "breaks", factors = list(wool = "wool", tension = "tension"), posthoc = FALSE ) draw_volcano_plot(estimate_significance(fact, log2fc_cutoff = 0.1)) by_term <- estimate_significance(fact, by = "term", log2fc_cutoff = 0.1) names(by_term$significance) by_term$significance[["wool:tension"]]
Predicts one or more fitted two-class models on the same rows and reports how
each one discriminated, with three tests of each model against a baseline
where more than one was passed. Every model is read through
predict.sa_model() with type = "response", so the five fitting functions
are interchangeable here and a logistic regression, a penalized one, a forest
and a machine are scored by the same arithmetic.
evaluate_classification_models( baseline_model, new_models = NULL, newdata, answer = NULL, outcome_lv = NULL, control_label = NULL, threshold = 0.5, conf_level = 0.95, baseline_label = "baseline" )evaluate_classification_models( baseline_model, new_models = NULL, newdata, answer = NULL, outcome_lv = NULL, control_label = NULL, threshold = 0.5, conf_level = 0.95, baseline_label = "baseline" )
baseline_model |
The reference model, as returned by
|
new_models |
Named list of further models to hold against it, such as
|
newdata |
The rows to score, typically the test half of a
|
answer |
The observed classes, either the name of a column of |
outcome_lv |
The two classes, reference first, checked against the order
the models were fitted with rather than used to change it. |
control_label |
The reference class on its own, checked the same way. |
threshold |
Where to cut the predicted probability for |
conf_level |
Confidence level of every interval in the result. |
baseline_label |
What to call the baseline in the tables and the legend. |
The rows are the intersection rather than the union. predict() on a model
answers NA for a row that is incomplete across that model's predictors,
so models fitted on different predictor sets come back with different rows
filled in. Scoring each on whatever it managed would put two AUCs from two
samples in one table, and all three comparisons below are paired
statistics that have no meaning at all across different rows. A row any model
cannot predict is therefore left out of all of them, with one message saying
how many went and why.
The direction is the fits'. outcome_lv[2] is the class
predict(type = "response") reports the probability of, so it is the class
every number here is about: sensitivity is measured against it, and an AUC
above 0.5 means the models rank it above the reference. outcome_lv and
control_label are read as a statement to be checked rather than as an
instruction, since a fitted model cannot be re-pointed after the fact, and
naming the other class is an error rather than a silent reversal. All the
models must agree on it too, which evaluate_regression_models() has no
counterpart of.
$metrics reports what needs no threshold first. auc comes with the
interval its own DeLong standard error gives, which is a Wald interval on a
bounded quantity and can therefore run past 1 for a strong classifier;
brier is the mean squared distance between the probability and the outcome,
which an AUC is blind to, since a model that ranks perfectly and predicts
every event at 0.6 has an AUC of 1. accuracy, sensitivity and
specificity do need one, and it is threshold, recorded in $parameters
so that the table says what it was measured at.
$comparisons asks three different questions of the same pair of models,
which is why all three are reported rather than one being chosen.
delta_aucWhether the ranking improved, tested by DeLong's paired test. Blind to any change that does not reorder rows.
idiHow much further apart the two classes' predicted probabilities moved, on the probability scale. Sees exactly the change an AUC does not.
nriHow often a probability moved the right way, counting direction only, so a model that helps many rows slightly and hurts a few badly scores well here and can score badly on the IDI. Category-free: no risk strata are named, since their cut points are a clinical convention rather than a property of the data.
Every one of them is new - baseline, and every one is positive for a new
model that did better.
An object of class sa_performance, a plain list of nine elements.
analysis"classification_performance".
modelsModel names, the baseline first, in the row order every table follows.
designWhat was scored: the outcome label, its type, its
outcome_lv, the baseline name, the row counts n_obs, n_used and
n_dropped, and n_events, how many scored rows were
outcome_lv[2].
parametersthreshold and conf_level.
predictionsOne row per model and scored row: model, row
(the position in newdata), observed (1 for outcome_lv[2], 0 for
the reference) and predicted (the probability of outcome_lv[2]).
metricsOne row per model: n_used, n_events, auc with its
interval, brier, and accuracy, sensitivity and specificity at
threshold.
comparisonsOne row per model other than the baseline, holding
delta_auc, idi and nri, each with its interval and p-value, and
the two class-wise components nri_event and nri_nonevent. Absent
when nothing was compared.
curvesThe ROC operating points, one row per model and distinct
predicted value: model, threshold, sensitivity, specificity.
Each curve opens at threshold = Inf, the cut above every prediction,
so that it starts at the corner where nothing is called an event.
metadataPackage version, R version, platform and timestamp.
DeLong, E. R., DeLong, D. M. and Clarke-Pearson, D. L. (1988). Comparing the areas under two or more correlated receiver operating characteristic curves: a nonparametric approach. Biometrics, 44(3), 837-845.
Pencina, M. J., D'Agostino, R. B., D'Agostino, R. B. and Vasan, R. S. (2008). Evaluating the added predictive ability of a new marker: from area under the ROC curve to reclassification and beyond. Statistics in Medicine, 27(2), 157-172.
evaluate_regression_models() for the continuous counterpart,
draw_roc_curve() for the picture of this result, and
predict.sa_model() for the call every model is read through.
## Two models of the same two-class outcome, scored on held-out rows. iris2 <- iris[iris$Species != "setosa", ] iris2$Species <- factor(iris2$Species) train <- iris2[c(1:35, 51:85), ] test <- iris2[c(36:50, 86:100), ] full <- fit_logistic_regression(train, outcome = "Species", cv = FALSE) petal <- fit_logistic_regression(train, outcome = "Species", predictors = "Petal.Width", cv = FALSE) res <- evaluate_classification_models(full, list(petal_only = petal), newdata = test) res res$metrics## Two models of the same two-class outcome, scored on held-out rows. iris2 <- iris[iris$Species != "setosa", ] iris2$Species <- factor(iris2$Species) train <- iris2[c(1:35, 51:85), ] test <- iris2[c(36:50, 86:100), ] full <- fit_logistic_regression(train, outcome = "Species", cv = FALSE) petal <- fit_logistic_regression(train, outcome = "Species", predictors = "Petal.Width", cv = FALSE) res <- evaluate_classification_models(full, list(petal_only = petal), newdata = test) res res$metrics
Predicts one or more fitted regressions on the same rows and reports how each
one did, with the differences against a baseline where more than one model
was passed. Every model is read through predict.sa_model(), so the five
fitting functions are interchangeable here and a linear model, a penalized
one, a forest and a machine are scored by the same arithmetic.
evaluate_regression_models( baseline_model, new_models = NULL, newdata, answer = NULL, baseline_label = "baseline" )evaluate_regression_models( baseline_model, new_models = NULL, newdata, answer = NULL, baseline_label = "baseline" )
baseline_model |
The reference model, as returned by
|
new_models |
Named list of further models to hold against it, such as
|
newdata |
The rows to score, typically the test half of a
|
answer |
The observed outcome, either the name of a column of |
baseline_label |
What to call the baseline in the tables and the legend. |
The rows are the intersection rather than the union. predict() on a model
answers NA for a row that is incomplete across that model's predictors,
so a baseline fitted on nine columns and a reduced model fitted on four
disagree about any row missing one of the extra five. Scoring each model on
whatever it managed would put two numbers from two samples in one table and
call their difference an improvement, so a row that any model cannot predict
is left out of all of them, and a single message reports how many went and
why. design$n_used and design$n_dropped record the outcome of that.
Every model must have been fitted to the same outcome, and to a continuous
one. A classification is refused by name and pointed at
evaluate_classification_models() rather than being scored by correlating a
probability against a class label, which would produce a number.
$metrics reports cor and r_squared side by side because they answer
different questions and agree only for predictions that need no calibration.
r_squared is 1 - SSE/SST on these rows, the fraction of the variance the
predictions actually removed, and it is negative for a model that does worse
than the mean of the outcome. cor^2 is what that would be if the
predictions were first rescaled by a line fitted to these same rows, so the
gap between the two is what calib_slope and calib_intercept describe: the
line is lm(predicted ~ observed), the same orientation
draw_prediction_plot() draws, so a slope under one is a model whose
predictions are compressed towards their own mean.
rmse and mae carry the names fit_linear_regression() and the rest use
in $performance, so a resampled score and a held-out score read in the same
unit under the same name. They are not the same number and are not meant to
be: one is measured inside the folds of the training data and the other on
rows drawn away from it.
$comparisons is every column of $metrics as new - baseline, so a
positive delta_cor and a negative delta_rmse both say the new model
did better. There is no p-value beside them, since a difference of held-out
errors has no null this function is in a position to state.
An object of class sa_performance, a plain list of eight elements.
analysis"regression_performance".
modelsModel names, the baseline first, in the row order every table follows.
designWhat was scored: the outcome label, its type, the
baseline name, and the row counts n_obs, n_used and n_dropped.
parametersEmpty. Scoring a regression takes no choices, which is the difference between this slot here and in a classification evaluation.
predictionsOne row per model and scored row: model, row
(the position in newdata), observed and predicted.
metricsOne row per model: n_used, cor, r_squared, rmse,
mae, bias, calib_slope and calib_intercept.
comparisonsOne row per model other than the baseline, holding
delta_cor, delta_r_squared, delta_rmse and delta_mae as
new - baseline. Absent when nothing was compared.
metadataPackage version, R version, platform and timestamp.
evaluate_classification_models() for the two-class counterpart,
draw_prediction_plot() for the picture of this result, and
predict.sa_model() for the call every model is read through.
## Two models of the same outcome, scored on rows neither was fitted on. train <- mtcars[1:24, ] test <- mtcars[25:32, ] full <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) small <- fit_linear_regression(train, outcome = "mpg", predictors = "wt", cv = FALSE) res <- evaluate_regression_models(full, list(weight_only = small), newdata = test) res res$metrics## Two models of the same outcome, scored on rows neither was fitted on. train <- mtcars[1:24, ] test <- mtcars[25:32, ] full <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) small <- fit_linear_regression(train, outcome = "mpg", predictors = "wt", cv = FALSE) res <- evaluate_regression_models(full, list(weight_only = small), newdata = test) res res$metrics
Fits a penalized linear model of one continuous or two-class outcome on a set of predictors, choosing how much to penalize by cross-validation, and scores the chosen model on the same folds. Which outcome it is decides which model is fitted: a numeric outcome is a linear regression and a two-class one is a logistic regression, both with the elastic net penalty on their coefficients.
fit_elastic_net( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], penalty = c("elastic_net", "lasso", "ridge"), alpha = seq(0, 1, by = 0.1), lambda = 10^seq(-4, 1, length.out = 50), cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )fit_elastic_net( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], penalty = c("elastic_net", "lasso", "ridge"), alpha = seq(0, 1, by = 0.1), lambda = 10^seq(-4, 1, length.out = 50), cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The outcome, either the name of a column of |
predictors |
Column names to fit on, or |
outcome_lv |
The two classes, reference first, so that the coefficients
describe the odds of the second one. Supplying it also states that the
outcome is to be classified, which is the one thing a numeric column holding
two values cannot say on its own. |
control_label |
The reference class on its own, for when the other one
needs no saying. Defaults to |
penalty |
Which penalty to apply: |
alpha |
Mixing weights to search, between 0 and 1, read only when
|
lambda |
Penalty sizes to search. Larger shrinks more, and 0 is no penalty at all. |
cv |
Whether to cross-validate. |
cv_method |
Resampling scheme: |
n_fold |
Folds per run, used by |
n_repeat |
Number of runs, used by |
seed |
Seed for the fold assignment, or |
The penalty is what makes this different from fit_linear_regression(). It
pulls the coefficients towards zero and, when alpha is above zero, sets some
of them exactly to zero, so the fit selects predictors as well as estimating
them. selected in the coefficient table is that answer.
penalty names a corner of one model rather than three different models.
alpha is the weight on the L1 penalty against the L2 one, so "lasso" is
alpha = 1, "ridge" is alpha = 0, and "elastic_net" is everything
between, alpha then being tuned like lambda. The first two ignore the
alpha argument, and parameters$alpha reports what was used either way.
This is the one place where this family departs from
fit_linear_regression(), where cv decides nothing about the fit.
lambda and alpha are chosen by the resampled metric, which is RMSE
for a regression and Accuracy for a classification, and the final model
is then fitted on all usable rows at that pair. performance therefore
holds one row per candidate, and parameters$alpha and
parameters$lambda are the pair that won. With cv = FALSE there is
nothing to choose between, so the grid must name exactly one candidate.
A penalized estimate is deliberately biased, and the
usual standard error assumes an unbiased one, so there is no honest number
to put under stderr, statistic, df, pval or the confidence limits.
The table carries only what a penalized fit answers — estimate and
selected — rather than those six columns filled with NA down their
whole length, which would read as a table that lost its values instead of a
model that never had them. is.null(fit$coefficients$pval) is how a
consumer tells the two kinds of table apart. Use
fit_linear_regression() or fit_logistic_regression() when the p-value
is the point.
glmnet
standardises each column before penalizing it, since otherwise a predictor
measured in millimetres would be shrunk less than the same predictor in
metres, and returns the coefficients on their original scale. They are
therefore comparable with each other in what they mean per unit, but not
with an unpenalized coefficient of the same predictor, which was not
shrunk at all.
As in the unpenalized models, a factor or
character predictor with k levels becomes k - 1 terms named after the
levels, and the coding is the one stats::lm() would have used, so the
two coefficient tables can be read side by side. Each of those terms is
penalized on its own, so a factor can have one level selected and another
dropped.
Rows with a missing value in the outcome or in any predictor are dropped
before the folds are drawn rather than inside each fit, and
design$n_dropped reports how many went. A predictor that takes a single
value is left out with a message, which is one of the two ways a call can end
up with fewer terms than it named.
Two terms are the fewest that can be fitted. A penalty divides a budget
between coefficients, and glmnet refuses a design matrix of one column
outright, so a single predictor is an error here rather than a model with
nothing to trade off. fit_linear_regression() and
fit_logistic_regression() fit one predictor as readily as ten.
For a two-class outcome the direction is outcome_lv, read exactly as
fit_logistic_regression() reads it: the first level is the reference, every
coefficient is the change in the log odds of outcome_lv[2], odds_ratio is
above 1 for a predictor that raises the chance of it, and
predict(model, newdata, type = "response") is its probability. That is
also what the engine does unaided, glmnet modelling the last level of a
factor as glm() does. control_label names the reference on its own and is
enough by itself to make a column of zeroes and ones a classification; naming
it alongside an outcome_lv that puts the other class first is an error, as
it is in fit_logistic_regression().
New rows are predicted through the result rather than through $fit, and on
this model that is the only route: glmnet was handed a design matrix and
reads one by position, so the frame the fit was given cannot be handed to it
again. predict.sa_model() rebuilds the terms from the levels the fit recorded
and puts them in the model's order by name.
A note on $fit: caret fits the whole lambda path and records the chosen
value on it, so coef() and predict() on $fit interpolate the path at
that value rather than reading a model fitted at it alone. The two agree to
within the resolution of the path, and everything the result reports comes
from the same interpolation, so the object is consistent with itself.
An object of class sa_model, the same eleven elements
fit_linear_regression() returns, with these differences:
analysis"elastic_net", whichever corner penalty named.
designHolds outcome_lv, n_events and event_rate for a
two-class outcome, as fit_logistic_regression() does, and neither for
a continuous one.
parametersHolds penalty, the alpha and lambda that were
chosen rather than the grids that were searched, and n_candidates, how
many pairs were scored. The grids themselves are the rows of
performance.
coefficientsestimate at the chosen penalty and selected,
whether the term survived it. The intercept is never penalized, so it is
always selected. The inference columns are absent rather than NA; a
two-class outcome also gets odds_ratio, the exponentiated estimate,
but no interval to go with it.
fit_statsMeasured on the rows the model was fitted to:
r_squared, rmse and mae for a regression, and null_deviance,
residual_deviance and mcfadden_r2 for a classification, each with
n_selected and n_zero, the terms the penalty kept and dropped. These
describe the fit rather than what it would do next; performance is the
one that was measured on rows the model had not seen.
performanceOne row per candidate, the chosen one being the row
that matches parameters$alpha and parameters$lambda, or NULL when
cv = FALSE.
fit_linear_regression() and fit_logistic_regression() for the
unpenalized fits and the inference they can report, split_data(), which
defines the rows this is fitted on, coef.sa_model() for the coefficient
table, predict.sa_model() for predicting the rows it was not fitted on, and
coef.sa_fit() for the methods $fit answers to, among them the estimates
as a named vector.
## A single lasso without resampling (fast enough for examples). fit <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 0.5, cv = FALSE) fit$parameters[c("penalty", "alpha", "lambda")] fit$coefficients[c("terms", "estimate", "selected")] ## Cross-validated grid search, ridge vs hard lasso, and known-truth scoring. fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = c(0.01, 0.1, 0.5, 1, 2), cv_method = "kfold", n_fold = 5, seed = 1) hard <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 2, cv = FALSE) ridge <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "ridge", lambda = 2, cv = FALSE) c(lasso = hard$fit_stats$n_selected, ridge = ridge$fit_stats$n_selected) sim <- simulate_regression(seed = 1) lasso <- do.call(fit_elastic_net, c(sim$args, list(penalty = "lasso", lambda = 0.5, cv = FALSE))) scored <- merge(lasso$coefficients, sim$truth_term, by = "terms") table(planted = scored$beta != 0, selected = scored$selected) iris2 <- iris[iris$Species != "setosa", ] clf <- fit_elastic_net(iris2, outcome = "Species", outcome_lv = c("versicolor", "virginica"), penalty = "lasso", lambda = c(0.001, 0.01, 0.1), cv_method = "kfold", n_fold = 5, seed = 1) clf$coefficients[c("terms", "estimate", "odds_ratio", "selected")]## A single lasso without resampling (fast enough for examples). fit <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 0.5, cv = FALSE) fit$parameters[c("penalty", "alpha", "lambda")] fit$coefficients[c("terms", "estimate", "selected")] ## Cross-validated grid search, ridge vs hard lasso, and known-truth scoring. fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = c(0.01, 0.1, 0.5, 1, 2), cv_method = "kfold", n_fold = 5, seed = 1) hard <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "lasso", lambda = 2, cv = FALSE) ridge <- fit_elastic_net(mtcars, outcome = "mpg", penalty = "ridge", lambda = 2, cv = FALSE) c(lasso = hard$fit_stats$n_selected, ridge = ridge$fit_stats$n_selected) sim <- simulate_regression(seed = 1) lasso <- do.call(fit_elastic_net, c(sim$args, list(penalty = "lasso", lambda = 0.5, cv = FALSE))) scored <- merge(lasso$coefficients, sim$truth_term, by = "terms") table(planted = scored$beta != 0, selected = scored$selected) iris2 <- iris[iris$Species != "setosa", ] clf <- fit_elastic_net(iris2, outcome = "Species", outcome_lv = c("versicolor", "virginica"), penalty = "lasso", lambda = c(0.001, 0.01, 0.1), cv_method = "kfold", n_fold = 5, seed = 1) clf$coefficients[c("terms", "estimate", "odds_ratio", "selected")]
Fits an ordinary least squares model of one continuous outcome on a set of
predictors, and scores it by cross-validation on the data it was fitted to.
Both halves of that sentence matter: the coefficient table describes the fit on
every usable row, while performance describes how the same procedure did on
rows it had not seen inside each fold.
fit_linear_regression( data, outcome, predictors = NULL, cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, conf_level = 0.95, seed = NULL )fit_linear_regression( data, outcome, predictors = NULL, cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, conf_level = 0.95, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The continuous outcome, either the name of a column of |
predictors |
Column names to fit on, or |
cv |
Whether to cross-validate. |
cv_method |
Resampling scheme: |
n_fold |
Folds per run, used by |
n_repeat |
Number of runs, used by |
conf_level |
Confidence level of the coefficient intervals. |
seed |
Seed for the fold assignment, or |
Nothing is selected on the outcome here. predictors is the set the caller
names, and a predictor that turns out not to matter stays in the table with the
p-value that says so.
The model is fitted by caret::train() with method = "lm", so the fit itself
is stats::lm()'s and the resampling is caret's. Two consequences are worth
knowing:
cv decides whether the
model is scored, not how it is fitted. The final model is fitted on all
usable rows either way, so the coefficients of cv = TRUE and
cv = FALSE are identical and only performance and resampling
differ.
A factor or character predictor with k
levels becomes k - 1 terms, named after the level each one stands for.
terms therefore holds the row order of coefficients, while
design$predictors holds the columns that were read.
Rows with a missing value in the outcome or in any predictor are dropped before
the folds are drawn rather than inside each fit. Left to the engine, deletion
would happen once per fold on whatever that fold held, and the folds would then
be scored on different subsets of the data; design$n_dropped reports how many
rows went.
The confidence interval is the t interval on the residual degrees of freedom,
the one that matches the t statistic and standard error reported beside it. A
term the fit could not estimate, because another predictor already spans it,
keeps its row with its estimate and inference NA and is named in a warning.
Dropping the row instead would make the table quietly shorter than the model
it describes.
An object of class sa_model, a plain list of eleven elements.
analysis"linear_regression".
termsCoefficient term names, in the row order coefficients
follows.
designWhat the model saw: the outcome label, its type, the
predictors kept and any dropped_predictors, and the row counts
n_obs, n_used and n_dropped.
parametersThe fitting choices, with the resampling arguments as
they were used rather than as they were passed: n_repeat is NA for a
scheme that does not repeat, and both fold arguments are NA for
leave-one-out.
coefficientsOne row per term: estimate, stderr,
statistic (t), df, pval, lower_conf and upper_conf.
fit_statsThe model as a whole: r_squared, adj_r_squared,
sigma, the overall F test, aic and bic.
performanceResampled RMSE, Rsquared and MAE with their
standard deviations across resamples, or NULL when cv = FALSE.
resamplingOne row per resample, or NULL.
engineWhat fitted the model, and which metrics it scored.
fitThe caret::train() object, so that
predict.sa_model() has something to predict with — with
type = "response" as well as caret's own "raw", the two meaning the
same thing for a regression — and so that coef(model$fit) and
summary(model$fit) answer as they would for the stats::lm() inside
it. This is the one element that is not portable; dropping it leaves an
object that writes out as JSON.
metadataPackage version, R version, platform and timestamp.
split_data(), which defines the rows this is fitted on,
fit_logistic_regression() for a two-class outcome, coef.sa_model() for
the coefficient table, predict.sa_model() for predicting the rows it was
not fitted on, and coef.sa_fit() for the methods $fit answers to, among
them the estimates as a named vector.
## Fitted without resampling (fast enough for examples). fit <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) fit$coefficients ## Cross-validation scores the model; it does not change the coefficients. scored <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv_method = "kfold", seed = 1) all.equal(fit$coefficients, scored$coefficients) ## A factor predictor becomes one term per level beyond the first. cars <- mtcars cars$cyl <- factor(cars$cyl) fit_linear_regression(cars, outcome = "mpg", predictors = c("wt", "cyl"), cv = FALSE)$terms ## The training half of a split is what a model is normally fitted on. sp <- split_data(mtcars, stratified = "mpg", seed = 1) train <- sp$datasets[[1]]$train_data fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE)$fit_stats$r_squared## Fitted without resampling (fast enough for examples). fit <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) fit$coefficients ## Cross-validation scores the model; it does not change the coefficients. scored <- fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv_method = "kfold", seed = 1) all.equal(fit$coefficients, scored$coefficients) ## A factor predictor becomes one term per level beyond the first. cars <- mtcars cars$cyl <- factor(cars$cyl) fit_linear_regression(cars, outcome = "mpg", predictors = c("wt", "cyl"), cv = FALSE)$terms ## The training half of a split is what a model is normally fitted on. sp <- split_data(mtcars, stratified = "mpg", seed = 1) train <- sp$datasets[[1]]$train_data fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE)$fit_stats$r_squared
Fits a binomial logistic regression of one two-class outcome on a set of
predictors, and scores it by cross-validation on the data it was fitted to. The
coefficient table describes the fit on every usable row, while performance
describes how the same procedure did on rows it had not seen inside each fold.
fit_logistic_regression( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, conf_level = 0.95, seed = NULL )fit_logistic_regression( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, conf_level = 0.95, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The two-class outcome, either the name of a column of |
predictors |
Column names to fit on, or |
outcome_lv |
The two classes, reference first, so that the coefficients
describe the odds of the second one. |
control_label |
The reference class on its own, for when the other one
needs no saying. Defaults to |
cv |
Whether to cross-validate. |
cv_method |
Resampling scheme: |
n_fold |
Folds per run, used by |
n_repeat |
Number of runs, used by |
conf_level |
Confidence level of the coefficient intervals. |
seed |
Seed for the fold assignment, or |
outcome_lv fixes the direction, and it does so by the rule the rest of the
package follows: the first level is the reference. Every coefficient is the
change in the log odds of outcome_lv[2] per unit of its predictor, and
odds_ratio is above 1 for a predictor that raises the chance of it. With
outcome_lv = c("control", "case") the table therefore reads as a statement
about case, and the same vector handed to compare_two_groups() as
group_lv would put control in the denominator of its fold change, so the
two point the same way.
control_label states the same direction with one name instead of two, which
is the shorter thing to say when the other class needs no saying. Naming both
and pointing them at different classes is an error rather than a re-pointing:
outcome_lv holds the two classes and nothing else, so there is no reading
under which a different reference leaves any of it standing. The comparison
functions take the argument the other way round, since their group_lv
carries the display order too — see compare_two_groups().
This is also what the engine does unaided. stats::glm() models the
probability of the last level of a factor, so ordering the levels
reference-first is the whole of the implementation and nothing is reversed
afterwards.
The rule reaches the predictions too:
predict(model, newdata, type = "response") is the probability of
outcome_lv[2], the same class the coefficients describe. See
predict.sa_model(), and coef.sa_fit() for the same word on the engine
object, since "response" is one this package adds to the ones
caret::train() accepts.
A third class is an error rather than a silently dropped set of rows: two
classes are what this model is, and quietly fitting a subset of the data that
was passed in would answer a question nobody asked. Reduce data to the two
classes first; naming two of three with outcome_lv is refused for the same
reason.
The rest matches fit_linear_regression(): the model is fitted by
caret::train() with method = "glm", cv decides whether the model is
scored rather than how it is fitted, a factor predictor with k levels becomes
k - 1 terms, and rows missing anything the model needs are dropped before
the folds are drawn. The interval is the Wald interval, the one matching the z
statistic and standard error reported beside it, rather than the profile
likelihood interval stats::confint() would give.
Perfect separation is reported rather than hidden. A predictor that splits the two classes exactly gives an estimate that grows until the fit stops, with a standard error to match, and the engine says so once per fold; those notes come back as one message with a count.
An object of class sa_model, the same eleven elements
fit_linear_regression() returns, with these differences:
analysis"logistic_regression".
designAlso holds outcome_lv, and n_events with event_rate,
the number and proportion of rows in outcome_lv[2].
coefficientsstatistic is a Wald z rather than a t and df is
NA, since the z is not referred to any. odds_ratio,
or_lower_conf and or_upper_conf are added, being the exponentiated
estimate and its limits.
fit_statsnull_deviance and residual_deviance with their
degrees of freedom, mcfadden_r2, the likelihood ratio test of the model
against the intercept alone, aic and bic.
performanceResampled Accuracy and Kappa rather than the
regression metrics.
split_data(), which defines the rows this is fitted on,
fit_linear_regression() for a continuous outcome, coef.sa_model() for
the coefficient table, predict.sa_model() for predicting the rows it was
not fitted on, and coef.sa_fit() for the methods $fit answers to, among
them the estimates as a named vector.
## Two of the three iris species, so that the outcome has two classes. ## `versicolor` first makes it the reference, so every odds ratio is the odds ## of `virginica` rather than of the other way round. iris2 <- iris[iris$Species != "setosa", ] fit <- fit_logistic_regression(iris2, outcome = "Species", predictors = c("Petal.Length", "Sepal.Width"), outcome_lv = c("versicolor", "virginica"), cv = FALSE) fit$coefficients[c("terms", "estimate", "odds_ratio", "pval")] fit$fit_stats$mcfadden_r2 ## Swapping the two levels turns every coefficient around and inverts every ## odds ratio, which is the same rule `group_lv` follows in a comparison. other_way <- fit_logistic_regression( iris2, outcome = "Species", predictors = c("Petal.Length", "Sepal.Width"), outcome_lv = c("virginica", "versicolor"), cv = FALSE ) cbind(versicolor_ref = fit$coefficients$odds_ratio, virginica_ref = other_way$coefficients$odds_ratio) ## The training half of a split is what a model is normally fitted on, and ## stratifying on the outcome keeps both halves able to see both classes. sp <- split_data(iris2, stratified = "Species", seed = 1) train <- sp$datasets[[1]]$train_data fit_logistic_regression(train, outcome = "Species", predictors = "Petal.Width", cv = FALSE)$design$n_events## Two of the three iris species, so that the outcome has two classes. ## `versicolor` first makes it the reference, so every odds ratio is the odds ## of `virginica` rather than of the other way round. iris2 <- iris[iris$Species != "setosa", ] fit <- fit_logistic_regression(iris2, outcome = "Species", predictors = c("Petal.Length", "Sepal.Width"), outcome_lv = c("versicolor", "virginica"), cv = FALSE) fit$coefficients[c("terms", "estimate", "odds_ratio", "pval")] fit$fit_stats$mcfadden_r2 ## Swapping the two levels turns every coefficient around and inverts every ## odds ratio, which is the same rule `group_lv` follows in a comparison. other_way <- fit_logistic_regression( iris2, outcome = "Species", predictors = c("Petal.Length", "Sepal.Width"), outcome_lv = c("virginica", "versicolor"), cv = FALSE ) cbind(versicolor_ref = fit$coefficients$odds_ratio, virginica_ref = other_way$coefficients$odds_ratio) ## The training half of a split is what a model is normally fitted on, and ## stratifying on the outcome keeps both halves able to see both classes. sp <- split_data(iris2, stratified = "Species", seed = 1) train <- sp$datasets[[1]]$train_data fit_logistic_regression(train, outcome = "Species", predictors = "Petal.Width", cv = FALSE)$design$n_events
Grows a forest of regression or classification trees on a set of predictors,
and scores it by cross-validation on the data it was fitted to. Which outcome
it is decides which forest is grown: a numeric outcome is a regression forest
and a two-class one a classification forest, as in fit_elastic_net(), so one
function covers what fit_linear_regression() and fit_logistic_regression()
cover between them.
fit_rf( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], mtry = NULL, ntree = 500, nodesize = NULL, cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )fit_rf( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], mtry = NULL, ntree = 500, nodesize = NULL, cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The outcome, either the name of a column of |
predictors |
Column names to fit on, or |
outcome_lv |
The two classes, reference first, so that the reported
sensitivity and the predicted probability are about the second one. Supplying
it also states that the outcome is to be classified, which is the one thing a
numeric column holding two values cannot say on its own. |
control_label |
The reference class on its own, for when the other one
needs no saying. Defaults to |
mtry |
Predictors offered at each split, one value to score or several to
choose between. |
ntree |
Trees to grow. More is steadier rather than more prone to overfitting, and costs time. |
nodesize |
Smallest number of rows a leaf may hold, so larger grows
shallower trees. |
cv |
Whether to cross-validate. |
cv_method |
Resampling scheme: |
n_fold |
Folds per run, used by |
n_repeat |
Number of runs, used by |
seed |
Seed for the forest and the fold assignment, or |
What comes back in place of a coefficient table is an importance table: one row per predictor, in descending order of how much the forest lost when that predictor was shuffled. It says which predictors carried the fit and not which way they pushed it, since a forest is free to use the same predictor in opposite directions in different regions of the data.
estimate is permutation importance, and there is no standard error
beside itEach tree is scored on its out-of-bag rows, then scored again
with one predictor's values shuffled among them, and estimate is the mean
loss over the trees: %IncMSE for a regression and
MeanDecreaseAccuracy for a classification. impurity is the other
measure the same fit reports, the total drop in node impurity that
predictor was responsible for — IncNodePurity or MeanDecreaseGini.
Neither is an estimate of anything a distribution is defined over, so the
inference columns are absent from the table rather than present and NA,
exactly as they are for fit_elastic_net(), and
is.null(fit$coefficients$pval) tells the two kinds of table apart.
The values are the unscaled ones. randomForest::importance() divides them
by their standard deviation across trees by default, which makes a t-shaped
ratio that is not referred to any distribution, so what is reported is the
mean loss itself, on the scale of the metric it was measured in.
Shuffling a predictor that carried nothing can leave the forest very slightly better than it was, and the mean loss then comes out below zero. It reads as it should: this predictor did no better than its own permutation.
A tree splits a factor on its levels
directly, so nothing is dummy coded and a k-level factor is one term
rather than k - 1. This is the one model here where terms and
design$predictors hold the same names — in a different order, since the
table is sorted by importance and the design records the order the columns
were read in.
fit_stats is measured out of bagEvery tree is grown on a bootstrap
sample of the rows, which leaves the rest of them out of bag for that tree,
and randomForest predicts each row from the trees that did not see it.
That is already a held-out score, and it is the honest one: a forest asked
to predict the rows it was fitted to reports something close to perfect
whatever the data held. The oob_ prefix says these numbers are not the
in-sample kind the other models report.
mtry when there is more than one to choose
frommtry is the only argument caret::train() tunes for a forest;
ntree and nodesize are the same for every candidate. A single value is
scored rather than chosen, as in fit_linear_regression(), and a vector is
compared the way fit_elastic_net() compares penalties, parameters$mtry
then holding the value that won and performance one row per candidate.
Unlike a penalty, mtry has a default worth fitting, so NULL resolves to
the rule of thumb — the square root of the predictor count for a
classification, a third of it for a regression — rather than to a grid, and
cv = FALSE needs nothing added to work.
Rows with a missing value in the outcome or in any predictor are dropped before
the folds are drawn rather than inside each fit, and design$n_dropped reports
how many went. A predictor that takes a single value is left out with a message.
One predictor is enough. There is no budget to divide as there is under a
penalty, so predictors = "wt" grows a forest of stumps on that one column
rather than raising the error fit_elastic_net() raises.
For a two-class outcome the direction is outcome_lv, read as
fit_logistic_regression() reads it: the first level is the reference, so
oob_sensitivity is the share of outcome_lv[2] the forest found,
oob_specificity the share of outcome_lv[1] it left alone, and
predict(model, newdata, type = "response") is the probability of
outcome_lv[2]. The importance table itself has no direction to report, which
is why there is no odds_ratio column beside it. control_label says the
same thing with one name instead of two and says it alone, so it too is enough
to make a column of zeroes and ones a classification. Naming both and pointing
them at different classes is an error rather than a re-pointing, since an
outcome_lv holds the two classes and nothing else; the comparison functions
read the argument the other way, as compare_two_groups() describes.
A forest is random beyond the folds — the rows of each tree and the predictors
of each split are both draws — so seed fixes more here than it does in the
other models, where it fixes only the fold assignment. Two calls without one
give slightly different importance values on the same data.
An object of class sa_model, the same eleven elements
fit_linear_regression() returns, with these differences:
analysis"random_forest".
termsThe predictors, in descending order of importance, since a forest expands no factor into dummy terms.
designHolds outcome_lv, n_events and event_rate for a
two-class outcome, as fit_logistic_regression() does, and neither for a
continuous one.
parametersHolds the mtry that was fitted rather than the grid
that was searched, ntree, nodesize, and n_candidates, how many
values of mtry were scored. The grid itself is the rows of
performance.
coefficientsestimate, the permutation importance, and
impurity, the impurity-based one. There is no intercept row, no
odds_ratio and no inference column.
fit_statsMeasured on the out-of-bag predictions: oob_r_squared,
oob_rmse and oob_mae for a regression, and oob_accuracy,
oob_error, oob_kappa, oob_sensitivity and oob_specificity for a
classification, each with n_oob, the rows that were out of bag of at
least one tree and so could be predicted.
performanceOne row per value of mtry, the chosen one being the
row that matches parameters$mtry, or NULL when cv = FALSE.
fit_linear_regression(), fit_logistic_regression() and
fit_elastic_net() for the models that do report an effect per predictor,
split_data(), which defines the rows this is fitted on, coef.sa_model()
for the importance table, and predict.sa_model() for predicting the rows it
was not fitted on.
## A single forest without resampling (fast enough for examples). fit <- fit_rf(mtcars, outcome = "mpg", ntree = 50, cv = FALSE, seed = 1) fit$coefficients fit$fit_stats ## Cross-validated fit and an `mtry` search. fit_rf(mtcars, outcome = "mpg", ntree = 200, cv_method = "kfold", n_fold = 5, seed = 1) tuned <- fit_rf(mtcars, outcome = "mpg", mtry = c(2, 5, 10), ntree = 200, cv_method = "kfold", n_fold = 5, seed = 1) tuned$parameters[c("mtry", "n_candidates")] ## Two-class outcome and a simulated regression scored against known truth. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_rf(iris2, outcome = "Species", outcome_lv = c("versicolor", "virginica"), ntree = 200, cv = FALSE) clf$fit_stats[c("oob_accuracy", "oob_sensitivity", "oob_specificity")] sim <- simulate_regression(seed = 1) rf <- do.call(fit_rf, c(sim$args, list(ntree = 200, cv = FALSE))) scored <- merge(rf$coefficients, sim$truth_term, by = "terms") tapply(scored$estimate, scored$beta != 0, mean)## A single forest without resampling (fast enough for examples). fit <- fit_rf(mtcars, outcome = "mpg", ntree = 50, cv = FALSE, seed = 1) fit$coefficients fit$fit_stats ## Cross-validated fit and an `mtry` search. fit_rf(mtcars, outcome = "mpg", ntree = 200, cv_method = "kfold", n_fold = 5, seed = 1) tuned <- fit_rf(mtcars, outcome = "mpg", mtry = c(2, 5, 10), ntree = 200, cv_method = "kfold", n_fold = 5, seed = 1) tuned$parameters[c("mtry", "n_candidates")] ## Two-class outcome and a simulated regression scored against known truth. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_rf(iris2, outcome = "Species", outcome_lv = c("versicolor", "virginica"), ntree = 200, cv = FALSE) clf$fit_stats[c("oob_accuracy", "oob_sensitivity", "oob_specificity")] sim <- simulate_regression(seed = 1) rf <- do.call(fit_rf, c(sim$args, list(ntree = 200, cv = FALSE))) scored <- merge(rf$coefficients, sim$truth_term, by = "terms") tapply(scored$estimate, scored$beta != 0, mean)
Fits a support vector machine with a radial basis kernel on a set of
predictors, choosing the cost and the kernel width by cross-validation, and
scores the chosen machine on the same folds. Which outcome it is decides which
machine is fitted: a numeric outcome is a support vector regression and a
two-class one a classification, as in fit_elastic_net() and fit_rf(), so one
function covers what fit_linear_regression() and fit_logistic_regression()
cover between them.
fit_svm( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], C = 2^seq(-5, 10, by = 2), sigma = NULL, n_permute = 10, cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )fit_svm( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], C = 2^seq(-5, 10, by = 2), sigma = NULL, n_permute = 10, cv = TRUE, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The outcome, either the name of a column of |
predictors |
Column names to fit on, or |
outcome_lv |
The two classes, reference first, so that the reported
sensitivity and the predicted probability are about the second one. Supplying
it also states that the outcome is to be classified, which is the one thing a
numeric column holding two values cannot say on its own. |
control_label |
The reference class on its own, for when the other one
needs no saying. Defaults to |
C |
Cost of violating the margin, one value to score or several to choose between. Larger fits the training rows harder and generalises less. |
sigma |
Width of the radial kernel on the standardised scale, one value or
several. Larger makes the kernel narrower, so each support vector reaches
fewer rows. |
n_permute |
Shuffles per term behind each importance. More is steadier and costs one prediction of every row per shuffle. |
cv |
Whether to cross-validate. |
cv_method |
Resampling scheme: |
n_fold |
Folds per run, used by |
n_repeat |
Number of runs, used by |
seed |
Seed for the kernel width, the class probabilities, the shuffles
behind the importance and the fold assignment, or |
What comes back in place of a coefficient table is an importance table: one row per term, in descending order of how much the machine lost when that term's values were shuffled among the rows. It says which terms carried the fit and not which way they pushed it, since a kernel machine bends its surface in different directions in different regions of the data.
estimate is permutation importance, and there is no standard error
beside itThe fitted machine is scored on the rows it was fitted to, then
scored again with one term's values shuffled among those rows, and
estimate is the mean loss over n_permute shuffles. The metric is the one
the resampling tuned on: the rise in RMSE for a regression and the fall in
Accuracy for a classification, so a larger value is a term the machine
needed either way. It is not an estimate of anything a distribution is
defined over, so the inference columns are absent from the table rather than
present and NA, exactly as they are for fit_elastic_net() and
fit_rf(), and is.null(fit$coefficients$pval) tells the two kinds of
table apart.
This is the
one number here that is weaker than fit_rf()'s counterpart. Every tree of
a forest leaves about a third of the rows out of its bootstrap sample, so
the permutation can be scored on rows that tree had not seen; a machine is
fitted on all of them at once and has no such rows. A term the machine
fitted noise with therefore earns some importance here that it would not
earn on held-out rows. performance is the number that was measured on rows
the procedure had not seen, and it describes the whole machine rather than
one term of it.
Shuffling a term that carried nothing can leave the machine very slightly better than it was, and the mean loss then comes out below zero. It reads as it should: this term did no better than its own permutation.
A factor or character predictor with k
levels becomes k - 1 terms named after the levels, and the coding is the
one stats::lm() would have used, so the tables can be read side by side.
Each of those terms is shuffled on its own, so one level of a factor can
matter to the machine and another not. fit_rf() is the model where the
terms are the predictors themselves, because a tree splits a factor
directly.
A radial kernel reads one
distance between two rows, so a predictor measured in millimetres would
dominate the same predictor in metres and the kernel width would mean
something different along each axis. Every term is centred and scaled before
the machine sees it, and predict.sa_model() applies the same centre and
scale to new rows, so this is not something to undo afterwards. It is also
why sigma is a width on the standardised scale rather than on the scale
the columns arrived in.
C and sigma
Both are chosen by the
resampled metric, which is RMSE for a regression and Accuracy for a
classification, and the final machine is then fitted on all usable rows at
the pair that won, as in fit_elastic_net(). performance therefore holds
one row per candidate and parameters the pair that was fitted. With
cv = FALSE there is nothing to choose between, so C and sigma must
name exactly one candidate.
sigma = NULL is a width read off the data rather than a grid, since the
distances between the rows are what fixes the scale a kernel is comparing them
on. kernlab::sigest() reports three widths, from the 10th, 50th and 90th
percentile of those distances, and the middle one is used. It samples pairs of
rows to do it, so this is one of the things seed fixes.
Rows with a missing value in the outcome or in any predictor are dropped before
the folds are drawn rather than inside each fit, and design$n_dropped reports
how many went. A predictor that takes a single value is left out with a message.
One predictor is enough. A distance can be measured along one axis as readily as
along ten, so a single column is a machine here rather than the error
fit_elastic_net() raises for want of a budget to divide.
For a two-class outcome the direction is outcome_lv, read as
fit_logistic_regression() reads it: the first level is the reference, so
sensitivity is the share of outcome_lv[2] the machine found, specificity
the share of outcome_lv[1] it left alone, and
predict(model, newdata, type = "response") is the probability of
outcome_lv[2]. Those probabilities are Platt's, a logistic curve fitted to the
decision values by an internal cross-validation of kernlab's own, which is a
second thing seed fixes and a reason two machines fitted without one predict
slightly different probabilities from the same decisions. The importance table
has no direction to report, which is why there is no odds_ratio column beside
it. control_label names the reference on its own and is enough by itself to
make a column of zeroes and ones a classification; naming it alongside an
outcome_lv that puts the other class first is an error, as it is in
fit_logistic_regression().
An object of class sa_model, the same eleven elements
fit_linear_regression() returns, with these differences:
analysis"svm".
termsThe model terms, in descending order of importance.
designHolds outcome_lv, n_events and event_rate for a
two-class outcome, as fit_logistic_regression() does, and neither for a
continuous one.
parametersHolds kernel, the C and sigma that were fitted
rather than the grids that were searched, n_candidates, how many pairs
were scored, and n_permute. The grids themselves are the rows of
performance.
coefficientsestimate, the permutation importance. There is no
intercept row, no odds_ratio and no inference column.
fit_statsMeasured on the rows the machine was fitted to:
r_squared, rmse and mae for a regression, and accuracy, error,
kappa, sensitivity and specificity for a classification, each with
n_support_vector and support_vector_rate, the rows the machine kept as
support vectors and their share of the rows it was fitted to. These
describe the fit rather than what it would do next; performance is the
one that was measured on rows the machine had not seen.
performanceOne row per candidate, the chosen one being the row
that matches parameters$C and parameters$sigma, or NULL when
cv = FALSE.
fit_rf() for the other model that answers with importance rather
than with coefficients, fit_linear_regression(),
fit_logistic_regression() and fit_elastic_net() for the models that do
report an effect per predictor, split_data(), which defines the rows this is
fitted on, coef.sa_model() for the importance table, and
predict.sa_model() for predicting the rows it was not fitted on.
## A single machine without resampling (fast enough for examples). fit <- fit_svm(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), C = 1, cv = FALSE, seed = 1) fit$coefficients fit$fit_stats ## Cross-validated fit and a search over `C`. fit_svm(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), C = 1, cv_method = "kfold", n_fold = 5, seed = 1) tuned <- fit_svm(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), C = c(0.5, 2, 8), cv_method = "kfold", n_fold = 5, seed = 1) tuned$parameters[c("C", "sigma", "n_candidates")] ## Two-class outcome and a simulated regression scored against known truth. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_svm(iris2, outcome = "Species", outcome_lv = c("versicolor", "virginica"), C = 1, cv = FALSE, seed = 1) clf$fit_stats[c("accuracy", "sensitivity", "specificity")] sim <- simulate_regression(seed = 1) svm <- do.call(fit_svm, c(sim$args, list(C = 1, cv = FALSE, seed = 1))) scored <- merge(svm$coefficients, sim$truth_term, by = "terms") tapply(scored$estimate, scored$beta != 0, mean)## A single machine without resampling (fast enough for examples). fit <- fit_svm(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), C = 1, cv = FALSE, seed = 1) fit$coefficients fit$fit_stats ## Cross-validated fit and a search over `C`. fit_svm(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), C = 1, cv_method = "kfold", n_fold = 5, seed = 1) tuned <- fit_svm(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), C = c(0.5, 2, 8), cv_method = "kfold", n_fold = 5, seed = 1) tuned$parameters[c("C", "sigma", "n_candidates")] ## Two-class outcome and a simulated regression scored against known truth. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_svm(iris2, outcome = "Species", outcome_lv = c("versicolor", "virginica"), C = 1, cv = FALSE, seed = 1) clf$fit_stats[c("accuracy", "sensitivity", "specificity")] sim <- simulate_regression(seed = 1) svm <- do.call(fit_svm, c(sim$args, list(C = 1, cv = FALSE, seed = 1))) scored <- merge(svm$coefficients, sim$truth_term, by = "terms") tapply(scored$estimate, scored$beta != 0, mean)
Assembles a correlation matrix out of groups of predictors that correlate with
each other, which is the structure worth simulating when the question is how a
model behaves under collinearity. Every predictor inside a block correlates
with every other one in it at the same value, and everything outside every
block correlates at default_cor. A block that names against is split in
two instead: each side agrees within itself and disagrees with the other side.
make_block_cor(n_features, blocks = list(), default_cor = 0)make_block_cor(n_features, blocks = list(), default_cor = 0)
n_features |
Number of predictors the matrix describes, so its size. |
blocks |
List of blocks, each a list with |
default_cor |
Correlation between any two predictors that are not in a
block together. The default of |
The matrix is what simulate_regression() and simulate_classification() take
as cor_mat. Its point there is that a null predictor correlated with a planted
one is not distinguishable from the planted one by the data alone, so its
estimated coefficient is drawn away from the zero it really has. That is the
single most common reason a coefficient table names the wrong predictor, and it
cannot be shown at all with independent predictors.
Each block is its own list(). A single list() that names features twice
is one block and not two, because $ reads the first of a repeated name and
nothing else, so the second pair of values would be dropped without a word.
Repeated and unknown names in a block are refused for that reason.
Blocks may not overlap. A predictor in two blocks would have two correlations
with the same partner and only one of them could be written down, so the second
block would silently win. Nest the smaller correlation as default_cor and
name one block instead, or, when the second group is what the first moves
against, name it as against in one block.
How strong a negative correlation one block can hold depends on how many
predictors are in it. A block of k predictors sharing one value is positive
definite only above -1/(k - 1): two predictors may disagree at -0.9, three at
no more than -0.5, four at no more than -0.333. Three predictors cannot all
disagree strongly, since whichever way the third moves it agrees with one of
the first two.
against is how a strong negative correlation is written down instead.
list(features = 1:3, cor = 0.9, against = 4:6) puts 0.9 among the first
three, 0.9 among the last three and -0.9 between the two sides. Splitting a
block by which way its predictors move leaves it positive definite for any
cor below 1 whatever its size, since it is then one factor with a sign per
predictor rather than a demand that everything disagree at once.
The result is checked for positive definiteness, which is the property that
separates a matrix of correlations from a matrix of numbers between -1 and 1.
The blocks and default_cor are checked one at a time first, so that a value
no block of that size could hold is named as such, and the eigenvalue of the
assembled matrix is what reports a default_cor the blocks cannot sit inside.
A symmetric n_features by n_features matrix with 1 on the diagonal.
simulate_regression() and simulate_classification(), which take the
result as cor_mat.
## Six predictors: the first two nearly interchangeable, the next three ## moderately related, and the sixth on its own. cor_mat <- make_block_cor( n_features = 6, blocks = list( list(features = 1:2, cor = 0.8), list(features = 3:5, cor = 0.5) ) ) round(cor_mat, 2) ## Handed to a simulator, it is what makes a null predictor look like a ## planted one. `x_2` has a coefficient of exactly zero and correlates 0.8 ## with a predictor that does not. sim <- simulate_regression(n_pred = 6, beta = c(2, 0, 0, 0, 0, 0), cor_mat = cor_mat, seed = 1) sim$truth[1:2, c("predictors", "role", "beta", "max_cor_signal")] ## Three predictors moving one way and three the other. `against` carries the ## sign, so -0.9 between the sides is available at any block size. opposed <- make_block_cor( n_features = 6, blocks = list(list(features = 1:3, cor = 0.9, against = 4:6)) ) round(opposed, 2) ## Three predictors cannot all disagree at -0.6, and the limit for a block of ## three is named rather than left to the matrix. try(make_block_cor(6, list(list(features = 1:3, cor = -0.6)))) ## Each block needs its own `list()`. One `list()` naming `features` twice is ## a single block whose second value R would never read. try(make_block_cor(6, list(list(features = 1:3, cor = 0.9, features = 4:6, cor = -0.4)))) ## Four predictors cannot all disagree with each other, so a request for it is ## refused here rather than met later by a draw that quietly ignores it. try(make_block_cor(4, default_cor = -0.5))## Six predictors: the first two nearly interchangeable, the next three ## moderately related, and the sixth on its own. cor_mat <- make_block_cor( n_features = 6, blocks = list( list(features = 1:2, cor = 0.8), list(features = 3:5, cor = 0.5) ) ) round(cor_mat, 2) ## Handed to a simulator, it is what makes a null predictor look like a ## planted one. `x_2` has a coefficient of exactly zero and correlates 0.8 ## with a predictor that does not. sim <- simulate_regression(n_pred = 6, beta = c(2, 0, 0, 0, 0, 0), cor_mat = cor_mat, seed = 1) sim$truth[1:2, c("predictors", "role", "beta", "max_cor_signal")] ## Three predictors moving one way and three the other. `against` carries the ## sign, so -0.9 between the sides is available at any block size. opposed <- make_block_cor( n_features = 6, blocks = list(list(features = 1:3, cor = 0.9, against = 4:6)) ) round(opposed, 2) ## Three predictors cannot all disagree at -0.6, and the limit for a block of ## three is named rather than left to the matrix. try(make_block_cor(6, list(list(features = 1:3, cor = -0.6)))) ## Each block needs its own `list()`. One `list()` naming `features` twice is ## a single block whose second value R would never read. try(make_block_cor(6, list(list(features = 1:3, cor = 0.9, features = 4:6, cor = -0.4)))) ## Four predictors cannot all disagree with each other, so a request for it is ## refused here rather than met later by a draw that quietly ignores it. try(make_block_cor(4, default_cor = -0.5))
Rotates a wide table of samples and features onto the axes that carry the most
variance, so that a few coordinates stand in for all of them. The components are
ordered by how much of the variance they carry, $variance says how much that
is, and $loadings says which features built each one.
perform_pca( data, feats = NULL, embedding_scale = c("samples", "features"), center = TRUE, scale = TRUE )perform_pca( data, feats = NULL, embedding_scale = c("samples", "features"), center = TRUE, scale = TRUE )
data |
A data.frame or a matrix in wide format, one row per sample and one
column per feature. This is the same layout |
feats |
Column names to reduce, or |
embedding_scale |
Which margin becomes the points of the picture:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation before rotating. Scaling is on by default because features are not
measured on a common scale, and without it the feature with the widest units
decides where the first component points. Both always apply to the columns of
|
The input is the wide format the comparison functions take: one row per sample and one column per feature. What comes back has one row per point, in one order every table follows, which is what makes a reduction plottable against anything else read from the same frame.
An object of class sa_reduction, a plain list.
analysis"pca".
pointsLabels of the things that became points — samples or
features, as embedding_scale asked — in the row order scores follows.
This is what features is to a comparison result.
designWhat was reduced: point_type, either "sample" or
"feature", the feats kept and any dropped_feats, and the counts
n_samples, n_used, n_dropped and n_feats. The counts describe the
input rather than the picture, so n_samples is always rows of data and
n_feats always kept columns, whichever margin became the points.
parametersembedding_scale as resolved, and the two flags.
varianceOne row per component: component, sdev, prop_var as
a percentage and cum_var. Every component is here, since a share of the
variance is only a share if the rest of it is there to be a share of.
loadingsThe margin that was not embedded: one row per variable,
variables beside one column per component.
scoresCoordinates: points beside one column per component.
engineWhat computed the rotation.
fitThe stats::prcomp() object, always fitted to the samples.
This is the slot that is not portable; dropping it leaves an object that
writes out as JSON.
metadataPackage version, R version, platform and timestamp.
$fit is the stats::prcomp() object itself, always fitted to the samples,
since that is the fit both margins are read from. $scores is the margin
embedding_scale asked for and is what a scatter plot of the points is drawn
from. On the default sample scale the two agree: $scores is $fit$x beside a
points column, and $loadings is $fit$rotation.
embedding_scale = "features" is how a map of the features is asked for, and
hand-transposing the input is not the same thing. stats::prcomp() centres and
scales columns: on a sample-by-feature matrix that standardises features,
which is what this method needs, and on the transpose it standardises samples
instead. So perform_pca(t(data)) runs a third analysis that is neither of the
two on offer. On the 8-feature simulation in the examples its first component
correlates 0.82 with the loadings, where embedding_scale = "features"
correlates 1.
What the argument does instead is leave the matrix alone. One decomposition
already answers on both margins, so the features are the rows of $rotation and
nothing has to be recomputed. What $scores reports is those loadings rescaled
to variance-weighted length, $rotation %*% diag(sdev * sqrt(n - 1)): the same
map, on the scale a coordinate has rather than the unit length a direction has.
$loadings is then the margin that was not embedded, one row per sample, and
$variance does not change at all, so the axis labels a plot carries are the
same on both scales.
Transposing by hand is right only when the rows of data really are features,
which is how an expression matrix usually arrives. Then the transpose puts the
data into this function's layout and embedding_scale chooses from there.
stats::prcomp() does not accept a missing value, so rows that are not complete
and finite across feats are dropped before it is called, and design$n_dropped
reports how many went. This is the listwise deletion the rest of the package
uses; nothing is imputed.
A feature that takes a single value cannot be scaled — the division is by zero —
so with scale = TRUE it is left out with a message and named in
design$dropped_feats. With scale = FALSE it stays and becomes a component of
no variance.
Everything but $fit is a data.frame, a character vector or a named list, so
dropping that one slot leaves an object that writes out as JSON. In a Python
transcription this is sklearn.decomposition.PCA.
perform_tsne() and perform_umap(), which answer the same question
in a way that can follow structure a rotation cannot, and draw_heatmap(),
which shows the same wide input a cell at a time instead of a point at a time.
## The sample coordinates are `$scores`, and `$variance` is what labels the axes. res <- perform_pca(iris[1:4]) res head(res$scores) res$variance plot(res$scores[c("PC1", "PC2")], xlab = paste0("PC1 (", round(res$variance$prop_var[1], 2), "%)"), ylab = paste0("PC2 (", round(res$variance$prop_var[2], 2), "%)"), col = as.integer(iris$Species), pch = 16) ## `$loadings` is the other axis: which features built each component. res$loadings ## On data with a planted two-group structure. The group was never shown to the ## rotation, so the split is its to find. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- perform_pca(sim$args$data) table(group = sim$args$group, side = by_samp$scores$PC1 > 0) ## `embedding_scale = "features"` turns the features into the points. Here the ## correlation blocks are planted, and the rotation was not told about them. cor_mat <- make_block_cor( n_features = 8, blocks = list(list(features = 1:2, cor = 0.8), list(features = 3:5, cor = 0.5), list(features = 7:8, cor = 0.9)) ) blocks <- simulate_classification(cor_mat = cor_mat, seed = 2026)$args$data by_feat <- perform_pca(blocks, feats = paste0("x_", 1:8), embedding_scale = "features") by_feat by_feat$points plot(by_feat$scores[c("PC1", "PC2")], type = "n", xlab = paste0("PC1 (", round(by_feat$variance$prop_var[1], 2), "%)"), ylab = paste0("PC2 (", round(by_feat$variance$prop_var[2], 2), "%)")) text(by_feat$scores[c("PC1", "PC2")], labels = by_feat$points, font = 2) ## The same fit read from the other end: the loadings of the sample scale, ## rescaled from unit length to variance-weighted length, on the same axis ## proportions. same <- perform_pca(blocks, feats = paste0("x_", 1:8)) all.equal(by_feat$variance, same$variance) round(diag(cor(by_feat$scores[-1], same$loadings[-1])), 6)## The sample coordinates are `$scores`, and `$variance` is what labels the axes. res <- perform_pca(iris[1:4]) res head(res$scores) res$variance plot(res$scores[c("PC1", "PC2")], xlab = paste0("PC1 (", round(res$variance$prop_var[1], 2), "%)"), ylab = paste0("PC2 (", round(res$variance$prop_var[2], 2), "%)"), col = as.integer(iris$Species), pch = 16) ## `$loadings` is the other axis: which features built each component. res$loadings ## On data with a planted two-group structure. The group was never shown to the ## rotation, so the split is its to find. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) by_samp <- perform_pca(sim$args$data) table(group = sim$args$group, side = by_samp$scores$PC1 > 0) ## `embedding_scale = "features"` turns the features into the points. Here the ## correlation blocks are planted, and the rotation was not told about them. cor_mat <- make_block_cor( n_features = 8, blocks = list(list(features = 1:2, cor = 0.8), list(features = 3:5, cor = 0.5), list(features = 7:8, cor = 0.9)) ) blocks <- simulate_classification(cor_mat = cor_mat, seed = 2026)$args$data by_feat <- perform_pca(blocks, feats = paste0("x_", 1:8), embedding_scale = "features") by_feat by_feat$points plot(by_feat$scores[c("PC1", "PC2")], type = "n", xlab = paste0("PC1 (", round(by_feat$variance$prop_var[1], 2), "%)"), ylab = paste0("PC2 (", round(by_feat$variance$prop_var[2], 2), "%)")) text(by_feat$scores[c("PC1", "PC2")], labels = by_feat$points, font = 2) ## The same fit read from the other end: the loadings of the sample scale, ## rescaled from unit length to variance-weighted length, on the same axis ## proportions. same <- perform_pca(blocks, feats = paste0("x_", 1:8)) all.equal(by_feat$variance, same$variance) round(diag(cor(by_feat$scores[-1], same$loadings[-1])), 6)
Runs a recursive feature elimination: the predictors are ranked, the weakest is dropped, and the model is scored again, over and over, so that every subset size gets a resampled score. What comes back is the size that scored best, the predictors of that size, and the whole profile the search walked, so that a choice of two predictors over eight can be read against what the other six were worth.
perform_rfe( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], model = c("linear", "logistic", "rf"), subset_sizes = NULL, metric = NULL, ntree = 500, nodesize = NULL, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )perform_rfe( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], model = c("linear", "logistic", "rf"), subset_sizes = NULL, metric = NULL, ntree = 500, nodesize = NULL, cv_method = c("repeated_kfold", "kfold", "loocv"), n_fold = 5, n_repeat = 5, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The outcome, either the name of a column of |
predictors |
Candidate column names, or |
outcome_lv |
For a two-class outcome, the two classes with the reference
first. |
control_label |
The reference class on its own, for when the other one
needs no saying. Defaults to |
model |
What is fitted inside the search: |
subset_sizes |
Subset sizes to score, or |
metric |
Which resampled number chooses the size: |
ntree, nodesize
|
Trees to grow and the smallest leaf to split, used by
|
cv_method |
Resampling scheme: |
n_fold |
Folds per run, used by |
n_repeat |
Number of runs, used by |
seed |
Seed for the fold assignment, or |
The input is the wide format the model functions take, one row per
observation with one column as the outcome, and is normally the training half
of a split_data() result. Selecting on data a model is later scored on is how
a selection flatters itself; see the details.
An object of class sa_selection, a plain list.
analysis"rfe".
candidatesThe predictors that were offered, most important first.
This is the row order ranking follows, and what terms is to a model.
designWhat the search saw: the outcome and its
outcome_type, outcome_lv with n_events and event_rate for a
classification, the row counts n_obs, n_used and n_dropped, the
predictors in the order they arrived, any dropped_predictors, and
predictor_lv for those that are factors.
parametersThe choices as they were used: model, metric and
maximize, the forest's ntree and nodesize when there was one, the
resampling scheme with NA where it used none of an argument, and
seed.
selectedThe predictors of the winning size, most important first.
These are the names to hand to predictors = in a fit_*() call.
rankingOne row per candidate: candidates, the estimate it was
ranked by averaged over the resamples, its rank, and whether it was
selected.
profileOne row per subset size that was scored: n_vars, one
column per metric with its standard deviation over the resamples, and
chosen, which is TRUE on exactly one row.
resamplingOne row per resample at the chosen size.
engineWhat ran the search, including importance, the name of
what ranking$estimate measures.
fitThe caret::rfe() object. This is the slot that is not
portable; dropping it leaves an object that writes out as JSON.
metadataPackage version, R version, platform and timestamp.
The elimination is inside the resampling, not before it. Each fold ranks the
predictors on its own training rows, peels them down to each size in turn, and
scores every size on rows it did not rank on, so a predictor that looks useful
only on the rows that chose it is caught. Ranking once on all the rows and
cross-validating afterwards is the mistake this ordering exists to avoid, and it
is why there is no cv argument: an elimination with nothing held out has no
score to choose a size by, so it is not a shorter version of this function but a
different and wrong one.
What the resampling cannot do is make the reported score an honest estimate of
the selected model. The size was chosen because it scored best, so profile
reads high at the size it picked, for the same reason a maximum of noisy numbers
is above their mean. Score the selection on the test half of split_data(),
which the search never saw, and read profile as the shape of the search rather
than as a performance claim.
The ranking is computed once per fold, at the full set of predictors, and the
peeling follows it. Re-ranking after every drop is caret's rerank and is a
far slower procedure — one refit per remaining predictor per fold — so it is not
what this function does.
model names what is fitted inside the search, and the outcome has to agree
with it. "linear" is a continuous outcome, "logistic" a two-class one, and
"rf" is either, following the outcome the way fit_rf() does. A disagreement
is an error naming the model that would have fitted, rather than a silently
different analysis.
What each ranks by is reported in engine$importance:
"linear", "logistic"
The absolute t or Wald z statistic of each coefficient, largest first. Not the coefficient itself, which is an effect per unit of its predictor and so would rank by the units the predictors were measured in; the statistic divides that by its own standard error and has no units left. A factor is ranked as one column, by the largest statistic among its levels, and a term the fit could not estimate ranks at zero, since a predictor the others already span costs nothing to drop.
"rf"Permutation importance, the loss when a predictor's values are
shuffled among the rows, measured out of bag. This is the same measure
fit_rf() reports as estimate, so a forest's ranking here and its
importance table there can be read together.
A forest inside the search grows at randomForest()'s own mtry for each
subset — the square root of the predictor count for a classification and a third
of it for a regression, which is the rule fit_rf() uses — rather than at one
value throughout. A fixed mtry would exceed the predictor count at the small
end of the profile, where the whole question is what a handful of predictors can
do.
outcome_lv is read as it is everywhere else in this package: the first level
is the reference, so outcome_lv = c("control", "case") searches for the
predictors of case. control_label names that same first level on its own, for
the usual case where the sort has it backwards and the other level needs no
saying. The two are the same statement, so passing both and disagreeing is an
error rather than a precedence rule.
For this function the direction changes nothing but the reading. A ranking is a
statement about how much a predictor is worth, which is the same number whichever
class is called the reference; what the levels decide is which class design
counts as the events, and which way a later fit_logistic_regression() on the
selected predictors will read.
The same listwise deletion the model functions use: rows that are missing the
outcome or any candidate go before the folds are drawn, so every fold sees the
same rows, and design$n_dropped says how many went. A candidate that takes a
single value is left out with a message, since a column with nothing in it
cannot be eliminated for a reason.
Everything but $fit is a scalar, a character vector, a named list or a
data.frame, so dropping that one slot leaves an object that writes out as JSON.
In a Python transcription this is sklearn.feature_selection.RFECV.
split_data(), which defines the rows a selection should be run on,
fit_rf() and fit_linear_regression() for fitting the predictors it kept,
and fit_elastic_net(), which answers the same question from the other end by
shrinking a coefficient to exactly zero rather than by dropping a column.
## Linear RFE on a small candidate set (fast enough for examples). res <- perform_rfe(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec"), cv_method = "kfold", n_fold = 3, seed = 1) res$selected res_full <- perform_rfe(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec", "drat", "carb"), cv_method = "kfold", n_fold = 3, seed = 1) fit_linear_regression(mtcars, outcome = "mpg", predictors = res_full$selected, cv = FALSE)$coefficients ## Forest ranking on a two-class outcome. iris2 <- iris[iris$Species != "setosa", ] perform_rfe(iris2, outcome = "Species", control_label = "versicolor", model = "rf", ntree = 100, cv_method = "kfold", n_fold = 3, seed = 1)## Linear RFE on a small candidate set (fast enough for examples). res <- perform_rfe(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec"), cv_method = "kfold", n_fold = 3, seed = 1) res$selected res_full <- perform_rfe(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec", "drat", "carb"), cv_method = "kfold", n_fold = 3, seed = 1) fit_linear_regression(mtcars, outcome = "mpg", predictors = res_full$selected, cv = FALSE)$coefficients ## Forest ranking on a two-class outcome. iris2 <- iris[iris$Species != "setosa", ] perform_rfe(iris2, outcome = "Species", control_label = "versicolor", model = "rf", ntree = 100, cv_method = "kfold", n_fold = 3, seed = 1)
Runs a stepwise search: the model is refitted with each candidate term taken out or put in, the move that lowers AIC or BIC the most is taken, and the search stops when no single move lowers it any further. What comes back is the predictors of the model it stopped at, the path it walked to get there, and what each candidate is worth to that model, so a set of four predictors can be read against what the other six would have cost.
perform_stepwise( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], model = c("linear", "logistic"), criterion = c("AIC", "BIC"), direction = c("backward", "both", "forward") )perform_stepwise( data, outcome, predictors = NULL, outcome_lv = NULL, control_label = outcome_lv[1], model = c("linear", "logistic"), criterion = c("AIC", "BIC"), direction = c("backward", "both", "forward") )
data |
A data.frame (or matrix) in wide format, one row per observation.
Typically the training half of a |
outcome |
The outcome, either the name of a column of |
predictors |
Candidate column names, or |
outcome_lv |
For a two-class outcome, the two classes with the reference
first. |
control_label |
The reference class on its own, for when the other one
needs no saying. Defaults to |
model |
What is fitted at every step: |
criterion |
Which criterion the moves are judged by, |
direction |
|
The input is the wide format the model functions take, one row per observation
with one column as the outcome, and is normally the training half of a
split_data() result. Selecting on data a model is later scored on is how a
selection flatters itself; see the details.
An object of class sa_selection, a plain list.
analysis"stepwise".
candidatesThe predictors that were offered, most important first.
This is the row order ranking follows, and what terms is to a model.
designWhat the search saw: the outcome and its outcome_type,
outcome_lv with n_events and event_rate for a classification, the
row counts n_obs, n_used and n_dropped, the predictors in the
order they arrived, any dropped_predictors, and predictor_lv for those
that are factors.
parametersThe choices as they were used: model, criterion,
maximize, which is FALSE because a smaller criterion is a better
model, k, the charge per parameter it levied, and direction.
selectedThe predictors of the model the search stopped at, most
important first. These are the names to hand to predictors = in a
fit_*() call.
rankingOne row per candidate: candidates, the estimate it was
ranked by, its rank, and whether it was selected. The estimate is what
leaving that one predictor out of the selected model costs the criterion,
so it is positive for a predictor worth keeping and negative for one worth
leaving out.
profileOne row per step of the path: n_vars, both AIC and
BIC of the model at that step, the step that was taken to reach it,
and chosen, which is TRUE on the last row.
resamplingNULL. Nothing was resampled.
engineWhat ran the search, including importance, the name of
what ranking$estimate measures.
fitThe stats::step() result, which is the selected lm or glm
with the path attached as $anova. This is the slot that is not portable;
dropping it leaves an object that writes out as JSON.
metadataPackage version, R version, platform and timestamp.
Both criteria are the fit's own log likelihood with a charge levied per
parameter, and they differ only in the size of the charge: "AIC" levies 2 and
"BIC" levies log(n), so past seven observations BIC charges more and keeps
fewer predictors. Smaller is better for both, which is why maximize is FALSE
and is reported rather than asked for. The charge as it was used is
parameters$k.
Whichever one searches, profile reports both at every step, so a path chosen
by AIC can be read against what BIC would have said about the same models. They
are stats::AIC()'s and stats::BIC()'s own numbers, the ones
fit_linear_regression() reports in fit_stats, so a step of the path and a
fitted model are the same number. stats::step() itself searches on
stats::extractAIC()'s scale, which for a linear model differs from
stats::AIC()'s by a constant that is the same for every model on n rows;
the path is therefore ordered identically and only the printed values differ.
A criterion is comparable across models only when they were fitted to the same
rows, which is what the listwise deletion below is for. It is not comparable
across outcomes, across transformations of an outcome, or between a linear and a
logistic fit, so the numbers in profile say which of these models to prefer
and nothing more.
The criterion is computed on the rows the model was fitted to, and the model was kept because it scored best on them. Three things follow, and none of them is a fault of the implementation:
Score the selection on the test half
of split_data(), which the search never saw. That is the only number here
that is a claim about new rows, and it is why this function has no cv
argument: cross-validating a penalised likelihood computed on the training
rows would resample the wrong quantity.
A coefficient
kept because it was significant enough to survive is being tested against a
null it was already screened on, so the p-value fit_linear_regression()
reports for it on the same rows reads smaller than it is. Refit on held-out
rows, or read the fit as a description of these rows rather than as a test.
One move is taken at a time, so a pair of predictors
that is worth keeping only together can be dropped one at a time and never
come back. direction = "both" reconsiders a dropped term at every later
step, which is what it is for; nothing short of fitting every subset removes
the problem entirely.
fit_elastic_net() answers the same question without a path at all, by
shrinking a coefficient to exactly zero, and perform_rfe() answers it with a
resampled score rather than with a penalty.
"backward" starts at every candidate and only drops. "forward" starts at the
intercept and only adds. "both" starts at every candidate and may add a term
back after dropping it, so its path can visit the same size twice; that is the
one direction whose profile is not a ladder.
Every direction is bounded by the same two models: the intercept alone below and
all of predictors above. A search that walks back to the intercept has kept
nothing, which is an answer — no candidate pays for itself at this charge — but
not one this contract can carry, since selected would be empty. It is an error
saying so rather than a result with a hole in it.
model names what is fitted at every step, and the outcome has to agree with
it: "linear" is a continuous outcome and "logistic" a two-class one. A
disagreement is an error naming the model that would have fitted, rather than a
silently different analysis. There is no "rf" here, unlike perform_rfe(): a
criterion is a likelihood with a charge against its parameter count, and a
forest has neither.
outcome_lv is read as it is everywhere else in this package: the first level
is the reference, so outcome_lv = c("control", "case") searches for the
predictors of case, and control_label names that first level on its own. For
the search itself the direction changes nothing, since the likelihood of a model
is the same number whichever class is called the reference; what the levels
decide is which class a later fit_logistic_regression() on $selected reads.
The same listwise deletion the model functions use: rows missing the outcome or
any candidate go first, and design$n_dropped says how many went. Here it is
also what makes the search legible, since a criterion compares models only when
they were fitted to the same rows, and leaving the deletion to the engine would
have each step fitted to whatever its own terms happened to be complete on. A
candidate that takes a single value is left out with a message, since a column
with nothing in it cannot earn a parameter.
Everything but $fit is a scalar, a character vector, a named list or a
data.frame, so dropping that one slot leaves an object that writes out as JSON.
split_data(), which defines the rows a selection should be run on,
perform_rfe(), which chooses by a resampled score rather than by a penalty,
fit_elastic_net(), which chooses by shrinking a coefficient to exactly zero,
and fit_linear_regression() and fit_logistic_regression() for fitting the
predictors it kept.
## Six candidates and one continuous outcome. The path says what was dropped and ## when, and `$selected` is where the search stopped. res <- perform_stepwise(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec", "drat", "carb")) res res$selected res$profile ## The same search at a heavier charge per parameter keeps fewer predictors. perform_stepwise(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec", "drat", "carb"), criterion = "BIC")$selected ## The selection is a set of column names, so it goes straight back into a fit. fit_linear_regression(mtcars, outcome = "mpg", predictors = res$selected, cv = FALSE)$coefficients ## A two-class outcome, with the reference named on its own. iris2 <- iris[iris$Species != "setosa", ] perform_stepwise(iris2, outcome = "Species", control_label = "versicolor", model = "logistic")## Six candidates and one continuous outcome. The path says what was dropped and ## when, and `$selected` is where the search stopped. res <- perform_stepwise(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec", "drat", "carb")) res res$selected res$profile ## The same search at a heavier charge per parameter keeps fewer predictors. perform_stepwise(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp", "qsec", "drat", "carb"), criterion = "BIC")$selected ## The selection is a set of column names, so it goes straight back into a fit. fit_linear_regression(mtcars, outcome = "mpg", predictors = res$selected, cv = FALSE)$coefficients ## A two-class outcome, with the reference named on its own. iris2 <- iris[iris$Species != "setosa", ] perform_stepwise(iris2, outcome = "Species", control_label = "versicolor", model = "logistic")
Places each point in two or three dimensions so that the points it was near in the full feature space stay near it, by t-distributed stochastic neighbour embedding. What comes back is a picture and the coordinates to draw it with: an axis is not a direction the way a principal component is, so there is nothing to read off one but position.
perform_tsne( data, feats = NULL, embedding_scale = c("samples", "features"), center = TRUE, scale = TRUE, n_dim = 2, perplexity = NULL, theta = 0.5, seed = NULL )perform_tsne( data, feats = NULL, embedding_scale = c("samples", "features"), center = TRUE, scale = TRUE, n_dim = 2, perplexity = NULL, theta = 0.5, seed = NULL )
data |
A data.frame or a matrix in wide format, one row per sample and one column per feature. Row names are kept as the sample labels, repeated ones included; rows without a name are labelled by position. |
feats |
Column names to embed, or |
embedding_scale |
Which margin becomes the points of the picture:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation before embedding. Both always apply to the columns of |
n_dim |
How many dimensions to embed into. |
perplexity |
The neighbourhood size, or |
theta |
Barnes-Hut approximation. |
seed |
Seed for the embedding, or |
The input is the wide format the comparison functions take: one row per sample and one column per feature, whichever margin is being embedded. What comes back has one row per point, in one order every table follows, which is what makes an embedding plottable against anything else read from the same frame.
An object of class sa_reduction, a plain list.
analysis"tsne".
pointsLabels of the things that became points — samples or
features, as embedding_scale asked — in the row order scores follows.
designWhat was embedded: point_type, either "sample" or
"feature", the feats kept and any dropped_feats, and the counts
n_samples, n_used, n_dropped and n_feats. The counts describe the
input rather than the picture.
parametersThe choices as they were used rather than as they were
passed, so a derived perplexity is the value that was derived.
scoresCoordinates: points beside tSNE1, tSNE2 and so on.
engineWhat computed the embedding, and which of its defaults were overridden.
fitThe Rtsne object. This is the slot that is not portable;
dropping it leaves an object that writes out as JSON.
metadataPackage version, R version, platform and timestamp.
Every distance this method measures is a distance across all of feats at once,
so a feature measured in thousands would decide who is whose neighbour. center
and scale are therefore on by default, as they are for perform_pca(), and the
two functions then see literally the same matrix — which is what lets the two
pictures be attributed to the methods rather than to the preprocessing.
perform_umap() defaults the other way, since UMAP is more often run on
coordinates that already mean something, such as the components of a PCA.
embedding_scale = "features" embeds the features instead, and unlike
perform_pca() this really does transpose: t-SNE embeds the rows it is handed
and has no second answer to read off the same fit. The features are standardised
first and the transpose is then embedded as it stands, which is what makes this
the same margin perform_pca(embedding_scale = "features") reports on.
Standardising after the transpose would standardise samples, which is what
perform_tsne(t(data)) does and why it is not the same call.
A feature margin needs enough features to be worth drawing. perplexity is read
off the number of points, so 8 features force a perplexity of 2 and a message
says so; at that size the loadings of a PCA are the whole answer and an embedding
has nothing to add. Sixty features derive a perplexity of 19, and on a simulation
with three planted correlation blocks the embedding then recovers them exactly.
Rtsne does not accept a missing value, so rows that are not complete and finite
across feats are dropped before it is called, and design$n_dropped reports how
many went. A feature that takes a single value cannot be scaled, so with
scale = TRUE it is left out with a message and named in design$dropped_feats.
Everything but $fit is a data.frame, a character vector or a named list, so
dropping that one slot leaves an object that writes out as JSON. In a Python
transcription this is sklearn.manifold.TSNE.
perform_pca(), which answers about the same points in a way that can
say which features moved them, and perform_umap(), the other embedding.
## The group was never shown to the embedding, so the split is its to find. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) res <- perform_tsne(sim$args$data, seed = 1) head(res$scores) plot(res$scores[c("tSNE1", "tSNE2")], col = as.integer(factor(sim$args$group)), pch = 16) pca <- perform_pca(sim$args$data) table(group = sim$args$group, side = pca$scores$PC1 > 0) ## Feature-scale embedding over planted correlation blocks. cor_mat <- make_block_cor( n_features = 60, blocks = list(list(features = 1:20, cor = 0.8), list(features = 21:40, cor = 0.6), list(features = 41:60, cor = 0.4)) ) blocks <- simulate_classification(n_pred = 60, cor_mat = cor_mat, seed = 2026)$args$data by_feat <- perform_tsne(blocks, feats = paste0("x_", 1:60), embedding_scale = "features", seed = 1) plot(by_feat$scores[c("tSNE1", "tSNE2")], col = rep(1:3, each = 20), pch = 16)## The group was never shown to the embedding, so the split is its to find. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) res <- perform_tsne(sim$args$data, seed = 1) head(res$scores) plot(res$scores[c("tSNE1", "tSNE2")], col = as.integer(factor(sim$args$group)), pch = 16) pca <- perform_pca(sim$args$data) table(group = sim$args$group, side = pca$scores$PC1 > 0) ## Feature-scale embedding over planted correlation blocks. cor_mat <- make_block_cor( n_features = 60, blocks = list(list(features = 1:20, cor = 0.8), list(features = 21:40, cor = 0.6), list(features = 41:60, cor = 0.4)) ) blocks <- simulate_classification(n_pred = 60, cor_mat = cor_mat, seed = 2026)$args$data by_feat <- perform_tsne(blocks, feats = paste0("x_", 1:60), embedding_scale = "features", seed = 1) plot(by_feat$scores[c("tSNE1", "tSNE2")], col = rep(1:3, each = 20), pch = 16)
Places each point in a few dimensions so that the neighbourhood structure of the full feature space survives, by uniform manifold approximation and projection. What comes back is a picture and the coordinates to draw it with: an axis is not a direction the way a principal component is, so there is nothing to read off one but position.
perform_umap( data, feats = NULL, embedding_scale = c("samples", "features"), center = FALSE, scale = FALSE, n_dim = 2, n_neighbors = NULL, min_dist = 0.1, method = c("naive", "umap-learn"), metric = c("euclidean", "manhattan", "cosine", "pearson"), seed = NULL )perform_umap( data, feats = NULL, embedding_scale = c("samples", "features"), center = FALSE, scale = FALSE, n_dim = 2, n_neighbors = NULL, min_dist = 0.1, method = c("naive", "umap-learn"), metric = c("euclidean", "manhattan", "cosine", "pearson"), seed = NULL )
data |
A data.frame or a matrix in wide format, one row per sample and one column per feature. Row names are kept as the sample labels, repeated ones included; rows without a name are labelled by position. |
feats |
Column names to embed, or |
embedding_scale |
Which margin becomes the points of the picture:
|
center, scale
|
Whether to centre each feature and divide it by its standard
deviation before embedding. Both are off by default, unlike |
n_dim |
How many dimensions to embed into. |
n_neighbors |
The neighbourhood size, or |
min_dist |
How tightly points that belong together are allowed to be packed. |
method |
Which |
metric |
Distance neighbours are measured with. |
seed |
Seed for the embedding, or |
The input is the wide format the comparison functions take: one row per sample and one column per feature, whichever margin is being embedded. What comes back has one row per point, in one order every table follows, which is what makes an embedding plottable against anything else read from the same frame.
An object of class sa_reduction, a plain list.
analysis"umap".
pointsLabels of the things that became points — samples or
features, as embedding_scale asked — in the row order scores follows.
designWhat was embedded: point_type, either "sample" or
"feature", the feats kept and any dropped_feats, and the counts
n_samples, n_used, n_dropped and n_feats. The counts describe the
input rather than the picture.
parametersThe choices as they were used rather than as they were
passed, so a derived n_neighbors is the value that was derived.
scoresCoordinates: points beside UMAP1, UMAP2 and so on.
engineWhat computed the embedding, and which of its defaults were overridden.
fitThe umap object. This is the slot that is not portable;
dropping it leaves an object that writes out as JSON.
metadataPackage version, R version, platform and timestamp.
center and scale are FALSE here and TRUE in perform_pca() and
perform_tsne(). The difference is metric: "cosine" and "pearson" compare
the shape of a row rather than its size, so they have already answered the
question standardising would answer, and the umap package does not standardise
either. The default is therefore the engine's own picture of the data as it
arrived.
What that leaves the caller is one decision rather than none. With
metric = "euclidean" or "manhattan" on features that are not measured on a
common scale, the feature with the widest units decides who is whose neighbour,
and scale = TRUE is what the picture needs — it is also what makes this
embedding comparable with a perform_pca() or perform_tsne() of the same data,
since those two standardise by default.
One consequence of the default is that a feature of no variance is kept. It can
be kept because nothing divides by its standard deviation; it contributes nothing
to any distance either way. With scale = TRUE it is left out with a message and
named in design$dropped_feats.
embedding_scale = "features" embeds the features instead, and unlike
perform_pca() this really does transpose: UMAP embeds the rows it is handed and
has no second answer to read off the same fit. center and scale, if they are
turned on, still apply to the features and the transpose is then embedded as
it stands, which is what makes this the same margin
perform_pca(embedding_scale = "features") reports on. perform_umap(t(data))
with scale = TRUE would standardise samples instead, which is a third analysis.
A feature margin needs enough features to be worth drawing. n_neighbors is read
off the number of points, so 8 features force a neighbourhood of 8 and a message
says so; at that size the loadings of a PCA are the whole answer.
umap does not accept a missing value, so rows that are not complete and finite
across feats are dropped before it is called, and design$n_dropped reports how
many went. This is the listwise deletion the rest of the package uses; nothing is
imputed.
Everything but $fit is a data.frame, a character vector or a named list, so
dropping that one slot leaves an object that writes out as JSON. The umap
object is the one genuinely unportable thing this package produces: its
config$metric.function is a function. In a Python transcription this is the
separate umap-learn package rather than anything in scikit-learn.
perform_pca(), which answers about the same points in a way that can
say which features moved them, and perform_tsne(), the other embedding.
## The group was never shown to the embedding, so the split is its to find. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) res <- perform_umap(sim$args$data, seed = 1) head(res$scores) plot(res$scores[c("UMAP1", "UMAP2")], col = as.integer(factor(sim$args$group)), pch = 16) scaled <- perform_umap(sim$args$data, scale = TRUE, seed = 1) ## Feature-scale embedding over planted correlation blocks. cor_mat <- make_block_cor( n_features = 60, blocks = list(list(features = 1:20, cor = 0.8), list(features = 21:40, cor = 0.6), list(features = 41:60, cor = 0.4)) ) blocks <- simulate_classification(n_pred = 60, cor_mat = cor_mat, seed = 2026)$args$data by_feat <- perform_umap(blocks, feats = paste0("x_", 1:60), embedding_scale = "features", scale = TRUE, seed = 1) plot(by_feat$scores[c("UMAP1", "UMAP2")], col = rep(1:3, each = 20), pch = 16)## The group was never shown to the embedding, so the split is its to find. sim <- simulate_two_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 3) res <- perform_umap(sim$args$data, seed = 1) head(res$scores) plot(res$scores[c("UMAP1", "UMAP2")], col = as.integer(factor(sim$args$group)), pch = 16) scaled <- perform_umap(sim$args$data, scale = TRUE, seed = 1) ## Feature-scale embedding over planted correlation blocks. cor_mat <- make_block_cor( n_features = 60, blocks = list(list(features = 1:20, cor = 0.8), list(features = 21:40, cor = 0.6), list(features = 41:60, cor = 0.4)) ) blocks <- simulate_classification(n_pred = 60, cor_mat = cor_mat, seed = 2026)$args$data by_feat <- perform_umap(blocks, feats = paste0("x_", 1:60), embedding_scale = "features", scale = TRUE, seed = 1) plot(by_feat$scores[c("UMAP1", "UMAP2")], col = rep(1:3, each = 20), pch = 16)
Dispatches on what was evaluated, since the two scenarios have different
pictures rather than one picture with a switch: a regression is drawn against
the outcome it predicted and a classification against the two classes it
ranked. Call draw_prediction_plot() or draw_roc_curve() directly to reach
their own arguments by name.
## S3 method for class 'sa_performance' plot(x, ...)## S3 method for class 'sa_performance' plot(x, ...)
x |
An evaluation, as returned by |
... |
Passed to whichever of the two is called. |
Whatever the function called returns, invisibly.
draw_prediction_plot() and draw_roc_curve().
train <- mtcars[1:24, ] test <- mtcars[25:32, ] fit <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) plot(evaluate_regression_models(fit, newdata = test))train <- mtcars[1:24, ] test <- mtcars[25:32, ] fit <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) plot(evaluate_regression_models(fit, newdata = test))
Takes the data frame the fit took — the test half of a split_data() result,
say — and answers one prediction per row of it. The columns are read by name
and coded the way the fit coded them, so the rows to predict can be handed over
exactly as they came, outcome column and all.
## S3 method for class 'sa_model' predict(object, newdata = NULL, type = c("raw", "response", "prob"), ...)## S3 method for class 'sa_model' predict(object, newdata = NULL, type = c("raw", "response", "prob"), ...)
object |
A fitted model, as returned by |
newdata |
Rows to predict, a data.frame or matrix carrying the predictor
columns, or |
type |
|
... |
Passed on to |
This is the method to use rather than predict(object$fit, newdata = ). The
engine object knows the names of the columns it was given and nothing about
where they came from, which is enough for fit_linear_regression(),
fit_logistic_regression() and fit_rf(), whose engine was handed the
predictor frame itself, and not enough for fit_elastic_net() or fit_svm(),
whose engines were handed a design matrix. glmnet and kernlab read that
matrix by position, and caret prepares newdata for it by keeping the columns
whose names it recognises, in the order newdata happens to hold them. A factor
predictor is therefore dropped whole, since x_cat is not one of the names —
the model has x_catmid and x_cathigh — and a set of numeric predictors in
another order is silently matched to the wrong coefficients, or measured along
the wrong axis of the kernel. Here the terms are rebuilt from the levels the fit
recorded and put in the model's own order by name, which is what makes one call
work for every model in the family.
Columns the model never saw are ignored, so the outcome column and anything else the frame carries can stay. A predictor that is absent is an error naming it, and so is a factor level the fit never saw, since neither has a coefficient to be predicted with.
There is one prediction per row of newdata whatever the row holds, and a row
with a missing value among the predictors gets NA. That is the rule the fit
already follows in reverse: those are the rows design$n_dropped counted, and
they cannot be predicted for the same reason they could not be fitted. Saying
so with an NA in place keeps the answer aligned with newdata, which a
shorter vector would not be. It also has to be said explicitly, because a
penalized fit would otherwise predict some of them: a coefficient of exactly
zero drops out of the sparse product before the missing value it multiplies is
ever read, so whether a row could be predicted would depend on which predictor
the hole fell in.
One prediction per row of newdata: an unnamed numeric vector for a
regression and for type = "response", a factor at the levels of
design$outcome_lv for type = "raw" on a classification, and a data.frame
of one column per class for type = "prob". Rows that are not complete
across the predictors are NA throughout.
fit_elastic_net(), fit_rf(), fit_svm(), split_data(), which
draws the rows to predict, and predict.sa_fit() for the engine object in
$fit, which is caret's own method and takes what caret prepared.
sp <- split_data(mtcars, seed = 1) train <- sp$datasets[[1]]$train_data test <- sp$datasets[[1]]$test_data fit <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) sqrt(mean((test$mpg - predict(fit, newdata = test))^2)) ## A factor predictor is what `predict(fit$fit, )` cannot be given on a ## penalized fit: the model has one term per level, and the frame has the ## column the levels came from. train$cyl <- factor(train$cyl) test$cyl <- factor(test$cyl, levels = levels(train$cyl)) pen <- fit_elastic_net(train, outcome = "mpg", predictors = c("wt", "hp", "cyl"), penalty = "lasso", lambda = 0.5, cv = FALSE) predict(pen, newdata = test) ## On a classification, `type = "response"` is the probability of the second ## level of `outcome_lv`, the one the coefficients describe. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_logistic_regression(iris2, outcome = "Species", predictors = "Petal.Length", outcome_lv = c("versicolor", "virginica"), cv = FALSE) head(predict(clf, newdata = iris2, type = "response"))sp <- split_data(mtcars, seed = 1) train <- sp$datasets[[1]]$train_data test <- sp$datasets[[1]]$test_data fit <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE) sqrt(mean((test$mpg - predict(fit, newdata = test))^2)) ## A factor predictor is what `predict(fit$fit, )` cannot be given on a ## penalized fit: the model has one term per level, and the frame has the ## column the levels came from. train$cyl <- factor(train$cyl) test$cyl <- factor(test$cyl, levels = levels(train$cyl)) pen <- fit_elastic_net(train, outcome = "mpg", predictors = c("wt", "hp", "cyl"), penalty = "lasso", lambda = 0.5, cv = FALSE) predict(pen, newdata = test) ## On a classification, `type = "response"` is the probability of the second ## level of `outcome_lv`, the one the coefficients describe. iris2 <- iris[iris$Species != "setosa", ] clf <- fit_logistic_regression(iris2, outcome = "Species", predictors = "Petal.Length", outcome_lv = c("versicolor", "virginica"), cv = FALSE) head(predict(clf, newdata = iris2, type = "response"))
Summarises the table, the null hypothesis it was held against, the tests run
on it and the association measures, rather than printing the cell table.
as.table() gives the table as counts, $cells the residuals and
$association the measures with their intervals.
## S3 method for class 'sa_categorical' print(x, alpha = 0.05, ...)## S3 method for class 'sa_categorical' print(x, alpha = 0.05, ...)
x |
A categorical comparison, as returned by
|
alpha |
Threshold the reported verdict on each test is read at. |
... |
Ignored, present for consistency with |
x invisibly.
compare_categorical_groups( data.frame(smoker = c(rep("y", 30), rep("n", 30)), grade = c(rep(c("high", "low"), c(22, 8)), rep(c("high", "low"), c(9, 21)))) )compare_categorical_groups( data.frame(smoker = c(rep("y", 30), rep("n", 30)), grade = c(rep(c("high", "low"), c(22, 8)), rep(c("high", "low"), c(9, 21)))) )
Summarises the rule that was applied and what cleared it, rather than printing
the table itself. Reach into x$significance for that.
## S3 method for class 'sa_categorical_significance' print(x, ...)## S3 method for class 'sa_categorical_significance' print(x, ...)
x |
An |
... |
Ignored, present for consistency with |
x invisibly.
res <- compare_categorical_groups( data.frame(smoker = rep(c("y", "n"), each = 30), grade = c(rep(c("high", "low"), c(22, 8)), rep(c("high", "low"), c(9, 21)))) ) estimate_categorical_significance(res) estimate_categorical_significance(res, by = "table")res <- compare_categorical_groups( data.frame(smoker = rep(c("y", "n"), each = 30), grade = c(rep(c("high", "low"), c(22, 8)), rep(c("high", "low"), c(9, 21)))) ) estimate_categorical_significance(res) estimate_categorical_significance(res, by = "table")
Summarises what was clustered and what came of it, rather than printing the
label of every point. Those are in x$assignments, and the engine object is
x$fit.
## S3 method for class 'sa_cluster' print(x, n = 10L, ...)## S3 method for class 'sa_cluster' print(x, n = 10L, ...)
x |
A clustering, as returned by |
n |
Maximum number of clusters to report the size of. The rest are counted. |
... |
Ignored, present for consistency with |
x invisibly.
cluster_kmeans(iris[1:4], n_clust = 3, seed = 1)cluster_kmeans(iris[1:4], n_clust = 3, seed = 1)
Summarises which tests were run and how many features each one called
significant, rather than printing the tables themselves. Reach into
x$tests for those, and into x$posthoc for the pairwise stage.
## S3 method for class 'sa_comparison' print(x, alpha = 0.05, ...)## S3 method for class 'sa_comparison' print(x, alpha = 0.05, ...)
x |
A comparison result, as returned by |
alpha |
Threshold applied to |
... |
Ignored, present for consistency with |
x invisibly.
iris2 <- iris[iris$Species != "setosa", ] compare_two_groups(iris2, c("Sepal.Length", "Petal.Length"), iris2$Species, c("versicolor", "virginica"))iris2 <- iris[iris$Species != "setosa", ] compare_two_groups(iris2, c("Sepal.Length", "Petal.Length"), iris2$Species, c("versicolor", "virginica"))
Reports how many features failed each check rather than printing the tables.
Reach into $normality, $variance and $outliers for those.
## S3 method for class 'sa_diagnosis' print(x, ...)## S3 method for class 'sa_diagnosis' print(x, ...)
x |
A diagnosis, as returned by |
... |
Ignored, present for consistency with |
x invisibly.
diagnose_distribution(iris, c("Sepal.Length", "Petal.Length"), iris$Species)diagnose_distribution(iris, c("Sepal.Length", "Petal.Length"), iris$Species)
Summarises what was fitted to what, and how it did, rather than printing every
table. The coefficient table is in x$coefficients, which is also what
coef() answers with; the resampled folds are in x$resampling, and the
engine object predict() and summary() take is x$fit.
## S3 method for class 'sa_model' print(x, n = 10L, ...)## S3 method for class 'sa_model' print(x, n = 10L, ...)
x |
A fitted model, as returned by |
n |
Maximum number of coefficient rows to show. The rest are counted. |
... |
Ignored, present for consistency with |
x invisibly.
fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE)fit_linear_regression(mtcars, outcome = "mpg", predictors = c("wt", "hp"), cv = FALSE)
Summarises what was scored on what, and how each model did, rather than
printing every table. The per-model scores are in x$metrics, what each
model did against the baseline is in x$comparisons, and the predictions
themselves are in x$predictions.
## S3 method for class 'sa_performance' print(x, n = 10L, ...)## S3 method for class 'sa_performance' print(x, n = 10L, ...)
x |
An evaluation, as returned by |
n |
Maximum number of models to show. The rest are counted. |
... |
Ignored, present for consistency with |
x invisibly.
## Two models of the same outcome, scored on rows neither was fitted on. train <- mtcars[1:24, ] test <- mtcars[25:32, ] full <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) small <- fit_linear_regression(train, outcome = "mpg", predictors = "wt", cv = FALSE) evaluate_regression_models(full, list(weight_only = small), newdata = test)## Two models of the same outcome, scored on rows neither was fitted on. train <- mtcars[1:24, ] test <- mtcars[25:32, ] full <- fit_linear_regression(train, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv = FALSE) small <- fit_linear_regression(train, outcome = "mpg", predictors = "wt", cv = FALSE) evaluate_regression_models(full, list(weight_only = small), newdata = test)
Summarises what was reduced and how far, rather than printing the coordinates.
Those are in x$scores, and the engine object is x$fit.
## S3 method for class 'sa_reduction' print(x, n = 3L, ...)## S3 method for class 'sa_reduction' print(x, n = 3L, ...)
x |
A reduction, as returned by |
n |
Maximum number of components to report the variance of. The rest are counted. Ignored by an embedding, which has no components. |
... |
Ignored, present for consistency with |
x invisibly.
perform_pca(iris[1:4])perform_pca(iris[1:4])
Summarises what was searched and what was kept, rather than printing every
table. The score of every model the search compared is in x$profile, which is
what says whether the answer won by much or by nothing, and the engine object is
x$fit.
## S3 method for class 'sa_selection' print(x, n = 10L, ...)## S3 method for class 'sa_selection' print(x, n = 10L, ...)
x |
A selection, as returned by |
n |
Maximum number of candidates to show. The rest are counted. |
... |
Ignored, present for consistency with |
x invisibly.
perform_rfe(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv_method = "kfold", n_fold = 3, seed = 1) ## The same method, on a search that walked a path rather than a ladder of ## subset sizes and that held no rows out to score it. perform_stepwise(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"))perform_rfe(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"), cv_method = "kfold", n_fold = 3, seed = 1) ## The same method, on a search that walked a path rather than a ladder of ## subset sizes and that held no rows out to score it. perform_stepwise(mtcars, outcome = "mpg", predictors = c("wt", "hp", "disp"))
Summarises the rule that was applied and how many features cleared it, rather
than printing the table itself. Reach into x$significance for that.
## S3 method for class 'sa_significance' print(x, ...)## S3 method for class 'sa_significance' print(x, ...)
x |
An |
... |
Ignored, present for consistency with |
x invisibly.
iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups(iris2, c("Sepal.Length", "Petal.Length"), iris2$Species, c("virginica", "versicolor")) estimate_significance(res, log2fc_cutoff = 0.1)iris2 <- iris[iris$Species != "setosa", ] res <- compare_two_groups(iris2, c("Sepal.Length", "Petal.Length"), iris2$Species, c("virginica", "versicolor")) estimate_significance(res, log2fc_cutoff = 0.1)
Summarises what the split was made on and how large each half came out,
rather than printing the data. The frames themselves are in
x$datasets[[i]]$train_data and $test_data.
## S3 method for class 'sa_split' print(x, ...)## S3 method for class 'sa_split' print(x, ...)
x |
A split, as returned by |
... |
Ignored, present for consistency with |
x invisibly.
split_data(iris, stratified = "Species", times = 2, seed = 1)split_data(iris, stratified = "Species", times = 2, seed = 1)
Screens each feature, or each feature within each group level, and returns one row per flagged observation. Nothing is deleted and no analysis changes as a result. Which observations belong in a data set is a decision about the experiment rather than about the arithmetic, and the package does not make it on the user's behalf.
screen_outliers( data, feats, group = NULL, group_lv = NULL, criterion = c("iqr", "robust_z", "grubbs"), iqr_multiplier = 1.5, z_threshold = 3.5, alpha = 0.05 )screen_outliers( data, feats, group = NULL, group_lv = NULL, criterion = c("iqr", "robust_z", "grubbs"), iqr_multiplier = 1.5, z_threshold = 3.5, alpha = 0.05 )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Optional grouping vector with one entry per row of |
group_lv |
Group levels to keep, in display order. Defaults to the
sorted unique values of |
criterion |
|
iqr_multiplier |
Fence width for |
z_threshold |
Cut-off for |
alpha |
Significance level for |
Three rules are available and they do not agree with each other, which is the point of naming the one used:
"iqr"Anything past Q1 - k * IQR or Q3 + k * IQR. The rule
behind the whiskers of a boxplot. It makes no distributional assumption
and flags a fixed share of any long-tailed sample.
"robust_z"Distance from the median in units of the median absolute deviation. Uses the median and MAD rather than the mean and standard deviation because one extreme value inflates the standard deviation enough to hide itself.
"grubbs"Tests only the single most extreme observation, against the null that the sample is normal. It is the only rule that produces a p-value and the only one that assumes a distribution.
A data.frame with one row per flagged observation and the columns
features, group (NA when no group was given), row (the row number
in the data that was passed in), value and score. The score is the
quantity the rule thresholded: distance past the nearer quartile in IQR
units for "iqr", the robust z for "robust_z", the Grubbs statistic for
"grubbs". Zero rows means nothing was flagged. The criterion and its
thresholds are attached as attributes.
Iglewicz, B. and Hoaglin, D. C. (1993). How to Detect and Handle Outliers. ASQC Quality Press.
Grubbs, F. E. (1969). Procedures for detecting outlying observations in samples. Technometrics, 11(1), 1-21.
diagnose_distribution(), which runs this alongside the normality
and variance checks, and summarize_descriptive_stats(), whose
out_lower_bound and out_upper_bound columns are the same IQR fences.
feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## Across the whole data set, sepal width is the only measurement with ## observations past the fences. screen_outliers(iris, feats) ## Within species, petal measurements pick up flags that the pooled screen ## hid: the between-species spread was widening the fences. screen_outliers(iris, feats, iris$Species) ## A stricter rule and a distributional one disagree, which is why the ## criterion is named rather than assumed. nrow(screen_outliers(iris, feats, criterion = "robust_z")) screen_outliers(iris, feats, criterion = "grubbs")feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## Across the whole data set, sepal width is the only measurement with ## observations past the fences. screen_outliers(iris, feats) ## Within species, petal measurements pick up flags that the pooled screen ## hid: the between-species spread was widening the fences. screen_outliers(iris, feats, iris$Species) ## A stricter rule and a distributional one disagree, which is why the ## criterion is named rather than assumed. nrow(screen_outliers(iris, feats, criterion = "robust_z")) screen_outliers(iris, feats, criterion = "grubbs")
Generates two or more categorical variables with a chosen amount of
association planted between them, and returns the planted answer alongside the
data. Every quantity compare_categorical_groups() estimates can then be
checked against what was actually put there, which is what a real data set can
never offer.
simulate_categorical_groups( n_samples = 200, category_lv = NULL, margins = NULL, assoc = 0.3, pattern = c("corner", "single", "gradient"), paired = FALSE, discordance = c(0.25, 0.1), seed = NULL )simulate_categorical_groups( n_samples = 200, category_lv = NULL, margins = NULL, assoc = 0.3, pattern = c("corner", "single", "gradient"), paired = FALSE, discordance = c(0.25, 0.1), seed = NULL )
n_samples |
Number of observations, meaning rows for a cross-classified design and subjects for a matched one. |
category_lv |
Named list giving the levels of each variable, passed
straight through to |
margins |
Named list of the marginal probabilities of each variable, in
the order its levels are given, or |
assoc |
How much association to plant, from 0 for exact independence to 1 for the largest margin-preserving departure the margins allow. |
pattern |
Which cells the association moves mass between. See "The shape of the association". |
paired |
Logical. If |
discordance |
The two transition probabilities of a matched design, from the first level to the second and back. Their ratio is the planted paired odds ratio, and their being equal is the strict null. |
seed |
Seed for the draw, restored on exit, so the caller's random stream is left as it was found. |
The point of the exercise is the gap. A table drawn from a distribution is not
that distribution: the observed Cramer's V carries a sampling error of its own,
and it is biased upwards, because every departure from independence counts
towards it whether it was planted or drawn. So a table simulated at
assoc = 0 does not come back with an estimated association of zero, and
reading that number as the answer rather than the p-value beside it is the
mistake the simulator exists to make visible.
A plain list, the shape every simulator in the package returns:
argsdata, category_lv and paired, named after the arguments
of compare_categorical_groups() so that
do.call(compare_categorical_groups, sim$args) runs the analysis this
data was made for.
truthOne row summarising what was planted: n_samples,
pattern, and the population value of every association measure the
comparison reports and this design defines. A cross-classification adds
assoc and cramers_v, plus phi_coefficient and odds_ratio on a
2 x 2 table. A matched design adds the transition probabilities, and then
either the three paired measures or the sequence of response rates.
These are the values the estimates should approach as n_samples grows,
and they carry no sampling error.
truth_cellOne row per cell: row_level, col_level,
p_independent, p_planted, lift and expected_n. It merges with
$cells of the comparison on c("row_level", "col_level") with neither
side renamed, so a residual can be read against the departure that
produced it. A matched pair of conditions adds p_symmetric and
expected_symmetry_n, which are what $cells$expected holds there:
symmetry is the null that design is tested against, so it is the one the
planted table has to be scored on. For three or more conditions the cells
are condition by response, and there marginal homogeneity and
independence are the same arithmetic, so p_independent already is the
null.
The margins are fixed first, and then mass is moved between the cells in a way
that leaves them exactly where they were. That is what assoc scales: a
perturbation matrix whose every row and column sums to zero, added to the
independent joint distribution.
Keeping the margins fixed is what makes the null hypothesis the only thing that moves. A test of independence compares the table to the product of its own observed margins, so a construction that shifted the margins too would plant an association and change what it is being measured against at the same time.
assoc is the fraction of the largest step of that shape the table can take
before a cell reaches zero, so it runs from 0 to 1 whatever the margins are.
assoc = 0 is the product of the margins exactly, so it is null in the
strict sense and is what a type I error rate can be measured on.
assoc = 1 puts a structural zero in the table, which is the one setting
where the exact test and the approximation part company sharply.
pattern picks which cells the mass moves between. All three keep the margins
fixed, so all three are the same kind of departure at different places.
"corner"Mass moves into the two cells where both variables sit at their first level or both at their second, and out of the two where they disagree. On a 2 x 2 table this is the whole of the association, and the odds ratio it plants is above 1.
"single"One level of each variable is special and the rest are alike: mass moves into the cell where both first levels meet and is taken evenly from the others. This is the shape a test of homogeneity is usually looking for, one category behaving differently from the pack.
"gradient"A monotone association, planted by a centred linear ramp
over the levels of each variable in the order category_lv gives them.
This is the shape an ordered variable such as high / mid / low
actually takes, and the one a chi-square test is least efficient at
finding, since it spends degrees of freedom on departures that are not
there.
paired = TRUE generates repeated binary measurements on the same subject
rather than a cross-classification, so assoc, pattern and margins do not
apply and discordance takes over. Every subject starts at either level with
equal probability, and then moves between consecutive conditions: a subject at
the first level takes the second with probability discordance[1], and one at
the second takes the first with probability discordance[2].
Two things follow from that, and both are what the matched tests are about.
Over two conditions the planted paired odds ratio is exactly
discordance[1] / discordance[2], because the equal start makes the two
discordant cells half of each transition probability. Over three or more, the
response rate climbs from condition to condition whenever the first
probability exceeds the second, and that climb is what Cochran's Q is testing
for. Equal probabilities are the strict null of both: the transitions cancel,
the rate stays at one half, and nothing is planted.
A cross-classification reads margins, assoc and pattern. A matched design
reads discordance. Passing one design an argument belonging to the other is a
warning naming both lists rather than a value silently ignored, because the two
designs plant different things and a call that names the wrong knob is a call
that expected the other design.
compare_categorical_groups() for the analysis this feeds, and
simulate_two_groups() for the numeric counterpart.
## A planted association, recovered by both tests. sim <- simulate_categorical_groups(n_samples = 300, assoc = 0.4, seed = 1) fit <- do.call(compare_categorical_groups, sim$args) fit$tests$chisq_test$pval ## The planted strength beside the estimated one. The estimate is high, which ## is what an association measure built from a sum of squares always is. cbind(planted = sim$truth$cramers_v, estimated = fit$association$estimate[1]) ## The residual of each cell against the lift that was planted there. scored <- merge(fit$cells, sim$truth_cell, by = c("row_level", "col_level")) scored[c("row_level", "col_level", "lift", "std_residual")] ## `assoc = 0` is the product of the margins exactly, so it is null in the ## strict sense: the lift of every cell is 1. null <- simulate_categorical_groups(n_samples = 300, assoc = 0, seed = 2) range(null$truth_cell$lift) ## A monotone association over ordered levels, which is the shape a chi-square ## test spends degrees of freedom it does not need on. A 3 x 3 table of this ## many observations is past what Fisher's enumeration can walk, so the ## simulated variant is what answers there. ordered <- simulate_categorical_groups( n_samples = 300, category_lv = list(dose = c("low", "mid", "high"), response = c("none", "partial", "full")), assoc = 0.5, pattern = "gradient", seed = 3 ) fit_ordered <- do.call(compare_categorical_groups, c(ordered$args, list(simulate_p_value = TRUE))) as.table(fit_ordered) ## A matched design plants the paired odds ratio as a ratio of transitions, ## and the cells carry what symmetry expects rather than what independence ## would, since symmetry is the null McNemar's test is about. matched <- simulate_categorical_groups(n_samples = 200, paired = TRUE, discordance = c(0.3, 0.1), seed = 4) matched$truth$odds_ratio_paired matched$truth_cell[c("row_level", "col_level", "p_symmetric", "expected_symmetry_n")]## A planted association, recovered by both tests. sim <- simulate_categorical_groups(n_samples = 300, assoc = 0.4, seed = 1) fit <- do.call(compare_categorical_groups, sim$args) fit$tests$chisq_test$pval ## The planted strength beside the estimated one. The estimate is high, which ## is what an association measure built from a sum of squares always is. cbind(planted = sim$truth$cramers_v, estimated = fit$association$estimate[1]) ## The residual of each cell against the lift that was planted there. scored <- merge(fit$cells, sim$truth_cell, by = c("row_level", "col_level")) scored[c("row_level", "col_level", "lift", "std_residual")] ## `assoc = 0` is the product of the margins exactly, so it is null in the ## strict sense: the lift of every cell is 1. null <- simulate_categorical_groups(n_samples = 300, assoc = 0, seed = 2) range(null$truth_cell$lift) ## A monotone association over ordered levels, which is the shape a chi-square ## test spends degrees of freedom it does not need on. A 3 x 3 table of this ## many observations is past what Fisher's enumeration can walk, so the ## simulated variant is what answers there. ordered <- simulate_categorical_groups( n_samples = 300, category_lv = list(dose = c("low", "mid", "high"), response = c("none", "partial", "full")), assoc = 0.5, pattern = "gradient", seed = 3 ) fit_ordered <- do.call(compare_categorical_groups, c(ordered$args, list(simulate_p_value = TRUE))) as.table(fit_ordered) ## A matched design plants the paired odds ratio as a ratio of transitions, ## and the cells carry what symmetry expects rather than what independence ## would, since symmetry is the null McNemar's test is about. matched <- simulate_categorical_groups(n_samples = 200, paired = TRUE, discordance = c(0.3, 0.1), seed = 4) matched$truth$odds_ratio_paired matched$truth_cell[c("row_level", "col_level", "p_symmetric", "expected_symmetry_n")]
Generates a two-class outcome from a logistic model of its predictors and
returns the coefficients that were planted alongside the data, so that a fitted
model can be scored against what was actually there. The same design arguments
simulate_regression() takes are available, together with the class balance,
which is the reason a split of a classification has to be stratified.
simulate_classification( n_samples = 200, n_pred = 8, n_pos = round(0.25 * n_pred), n_neg = round(0.25 * n_pred), beta = NULL, beta_range = c(0.5, 2), event_rate = 0.3, outcome_lv = c("control", "case"), value_mean = 0, value_sd = 1, cor_mat = NULL, n_factor_pred = 1, factor_lv = c("low", "mid", "high"), n_constant_pred = 0, p_missing = 0, n_per_subject = NULL, subject_sd = 1, subject_share = 0.5, pred_prefix = "x", seed = NULL )simulate_classification( n_samples = 200, n_pred = 8, n_pos = round(0.25 * n_pred), n_neg = round(0.25 * n_pred), beta = NULL, beta_range = c(0.5, 2), event_rate = 0.3, outcome_lv = c("control", "case"), value_mean = 0, value_sd = 1, cor_mat = NULL, n_factor_pred = 1, factor_lv = c("low", "mid", "high"), n_constant_pred = 0, p_missing = 0, n_per_subject = NULL, subject_sd = 1, subject_share = 0.5, pred_prefix = "x", seed = NULL )
n_samples |
Rows to generate. Ignored when |
n_pred |
Number of numeric predictors. Columns are named |
n_pos, n_neg
|
How many numeric predictors are given a positive and a
negative coefficient. Their sum cannot exceed |
beta |
The coefficients themselves, one per numeric predictor and no
intercept among them, or |
beta_range |
Range the magnitude of a planted coefficient is drawn from.
The offsets of the factor predictors are drawn from it too, so it is read
whether or not |
event_rate |
Proportion of rows in |
outcome_lv |
The two class labels, the reference first, so that the coefficients describe the odds of the second one. |
value_mean, value_sd
|
Mean and standard deviation of each numeric
predictor, given once for all of them or once each. A coefficient is a change
in the outcome per unit of its predictor, so these fix what |
cor_mat |
Correlation matrix of the numeric predictors, as built by
|
n_factor_pred |
Number of categorical predictors, named |
factor_lv |
Levels of each categorical predictor, the first being the reference that carries no offset. |
n_constant_pred |
Number of predictors that take a single value, named
|
p_missing |
Proportion of numeric predictor cells to replace with |
n_per_subject |
Rows measured on each subject, one entry per subject, so
that its length is how many subjects there are. A single number is spread over
|
subject_sd |
Standard deviation of the per-subject offset on the outcome,
which is variation no predictor accounts for. Ignored without
|
subject_share |
Share of each numeric predictor's variance that lies
between subjects rather than within one, so its intraclass correlation. This
is what makes two rows of one subject resemble each other, and it is that
resemblance a row-wise split gives away: at |
pred_prefix |
Prefix for the generated predictor names. |
seed |
Seed for the draw, or |
A classification differs from a regression in what can go wrong with it, and the
defaults are set so that all of it can be seen. Classes are imbalanced, so an
unstratified split can hand a fold too few events to fit on. A subject is a case
or a control as a whole, so a split that does not respect id scores the model
on rows whose label it already holds. And a predictor with a coefficient of
exactly zero is null in the strict sense, so an odds ratio away from 1 on one is
a mistake by definition rather than by judgement.
The linear predictor is
intercept + sum(beta * x) + factor offsets + subject offset, the class
probability is its logistic transform, and the class is a Bernoulli draw from
that probability. There is no noise argument: the draw is the noise, which is
why a logistic regression recovers less from the same number of rows than a
linear one does.
The intercept is not an argument. It is solved for so that the mean class
probability over the rows that were actually drawn equals event_rate, which
means the balance of the data is what was asked for rather than whatever the
coefficients happened to imply. truth_model$intercept reports the value it
took and truth_model$achieved_event_rate the proportion the Bernoulli draw
then produced.
outcome_lv fixes the direction by the rule the rest of the package follows:
the first level is the reference, so a planted positive coefficient raises the
chance of outcome_lv[2] and its odds ratio comes back above 1. It is carried
in args rather than left out, because fit_logistic_regression() sorts the
classes when it is not told them, and sort(c("case", "control")) puts case
first, which would report the odds of the wrong class.
A list of six elements, the same shape simulate_regression() returns,
with these differences:
argsAlso carries outcome_lv, since the direction of every
coefficient depends on it and the default would sort the labels the other
way round.
split_argsstratified is always the outcome. Unlike a continuous
one it is constant within a subject, so it stratifies a split over
subjects as readily as one over rows.
truth_modelintercept as solved, the event_rate asked for and
the achieved_event_rate the draw produced, signal_var, subject_var,
n_samples, n_subject and subject_sd. There is no r_squared: the
outcome is a draw, so no share of its variance is recoverable in that
sense.
truth_rowprob, the class probability of the row, and
draw_prob, the probability the Bernoulli draw actually used, which is the
subject's mean when there are subjects and prob itself when there are
not.
The same design simulate_regression() uses — eight numeric predictors, four of
them planted, one categorical predictor and 200 rows — at an event rate of 0.3,
so that the two simulators differ in the outcome and in nothing else. Averaged
over twenty seeds that recovers 0.83 of the planted coefficients at
p <= 0.05, the difficulty the rest of the simulators in this package are tuned
to. Coefficients of 0.5 to 2 on the log odds scale are odds ratios of roughly
1.6 to 7, which is what it takes for a class label to carry as much as a number
does: the Bernoulli draw is this model's noise and there is no argument to turn
it down.
fit_logistic_regression(), which consumes args directly,
simulate_regression() for a continuous outcome, split_data(), which
consumes split_args, and make_block_cor() for cor_mat.
sim <- simulate_classification(seed = 1) table(sim$args$data$y) ## The names in `args` are fit_logistic_regression()'s own, `outcome_lv` ## included, so the fit is one call away and points the way it was planted. fit <- do.call(fit_logistic_regression, c(sim$args, cv = FALSE)) ## A planted positive coefficient raises the chance of the second level, so ## its odds ratio is above 1. `truth_term` is in the row order the table uses. scored <- merge(fit$coefficients, sim$truth_term, by = "terms") scored[, c("terms", "beta", "estimate", "odds_ratio", "pval")] table(planted = scored$beta != 0, called = scored$pval <= 0.05) ## The intercept was not asked for, it was solved for: the balance of the data ## is the balance that was requested. c(asked = sim$truth_model$event_rate, drawn = sim$truth_model$achieved_event_rate) ## Two samples per subject, and a subject is a case or a control as a whole. ## The outcome can therefore still be the stratifier of a split over subjects. rep_sim <- simulate_classification(n_per_subject = rep(2, 100), seed = 2) sp <- do.call(split_data, c(rep_sim$split_args, seed = 1)) spsim <- simulate_classification(seed = 1) table(sim$args$data$y) ## The names in `args` are fit_logistic_regression()'s own, `outcome_lv` ## included, so the fit is one call away and points the way it was planted. fit <- do.call(fit_logistic_regression, c(sim$args, cv = FALSE)) ## A planted positive coefficient raises the chance of the second level, so ## its odds ratio is above 1. `truth_term` is in the row order the table uses. scored <- merge(fit$coefficients, sim$truth_term, by = "terms") scored[, c("terms", "beta", "estimate", "odds_ratio", "pval")] table(planted = scored$beta != 0, called = scored$pval <= 0.05) ## The intercept was not asked for, it was solved for: the balance of the data ## is the balance that was requested. c(asked = sim$truth_model$event_rate, drawn = sim$truth_model$achieved_event_rate) ## Two samples per subject, and a subject is a case or a control as a whole. ## The outcome can therefore still be the stratifier of a split over subjects. rep_sim <- simulate_classification(n_per_subject = rep(2, 100), seed = 2) sp <- do.call(split_data, c(rep_sim$split_args, seed = 1)) sp
The factorial counterpart of simulate_multiple_groups(). Crosses any number
of factors, lets each one be measured between subjects or within them, and
returns the planted answer alongside the data so that a two-way or an n-way
analysis can be scored against what was actually put there.
simulate_factorial_groups( n_feats = 100, factor_lv = list(treatment = c("control", "treat_A", "treat_B", "treat_C"), sex = c("male", "female")), within = NULL, n_per_cell = 20, n_up = round(0.15 * n_feats), n_down = round(0.15 * n_feats), term_mix = c(main_only = 1, additive = 1, interaction = 1, crossover = 1, nuisance_only = 1), pattern_mix = c(all = 1, gradient = 1, single = 1), expr_range = c(2, 12), ref_sd = c(1.2, 2.4), cell_sd = c(1.8, 3.2), deg_log2fc = c(1, 2.5), interaction_scale = 0.8, subject_sd = c(2, 4), feat_prefix = "prot", seed = NULL )simulate_factorial_groups( n_feats = 100, factor_lv = list(treatment = c("control", "treat_A", "treat_B", "treat_C"), sex = c("male", "female")), within = NULL, n_per_cell = 20, n_up = round(0.15 * n_feats), n_down = round(0.15 * n_feats), term_mix = c(main_only = 1, additive = 1, interaction = 1, crossover = 1, nuisance_only = 1), pattern_mix = c(all = 1, gradient = 1, single = 1), expr_range = c(2, 12), ref_sd = c(1.2, 2.4), cell_sd = c(1.8, 3.2), deg_log2fc = c(1, 2.5), interaction_scale = 0.8, subject_sd = c(2, 4), feat_prefix = "prot", seed = NULL )
n_feats |
Number of features to generate. Columns are named |
factor_lv |
Named list of factors, each entry the levels of one factor
with the reference level first. Its length is how many factors are crossed,
so there is no separate argument for that, and there have to be at least
two: one factor is The first factor is the primary one, the treatment the experiment is about, and the ones after it are the other factors the effect may or may not depend on. The shapes below are written in those terms. The cell in which every factor sits at its reference level is the reference cell, and it is what the planted effects are measured against. |
within |
Names of the factors measured within subjects, or |
n_per_cell |
Observations per cell. When there are within factors this is
also the number of subjects per combination of the between factors, because
each of those subjects contributes exactly one observation to each cell it
is measured in. One number spreads over every cell; a vector carries one
size per combination of the between factors, which makes its length how many
of those there are, the rule |
n_up, n_down
|
How many features are moved up and down. Their sum cannot
exceed |
term_mix |
Named vector of relative weights over the five shapes of effect described under "The five shapes" below, which decide which terms of the model an effect is planted in. Set a weight to zero to leave that shape out. The planted features are split between the shapes by the largest remainder method rather than drawn at random, so the counts are exactly what the weights ask for and do not move with the seed. |
pattern_mix |
Named vector of relative weights over |
expr_range |
Range the baseline log2 abundance of each feature is drawn from. Every cell shares the baseline, which is what makes an unplanted feature null in every term at once. |
ref_sd, cell_sd
|
Ranges the per-feature standard deviation of the
reference cell and of every other cell are drawn from. Every cell draws its
own, so the design is heteroscedastic and unbalanced variance is something
the analysis has to survive rather than something it is spared. Pass the
same range twice for equal variances. The defaults are the two ranges
Keeping them apart costs something that is worth knowing about. An |
deg_log2fc |
Range the magnitude of the planted effect is drawn from, on the log2 scale. One magnitude is drawn per planted feature and the shape decides how it is spread over the terms. |
interaction_scale |
Size of the interaction relative to the main effect
under the The default puts the |
subject_sd |
Range the per-feature subject standard deviation is drawn
from. A subject's offset is drawn once per feature and reused across every
condition it is measured under, which is what a within-subject test exists
to remove. Ignored when |
feat_prefix |
Prefix for the generated feature names. |
seed |
Seed for the draw, or |
One factor asks one question: are the levels alike. Crossing a second one asks three, and they fail separately and for different reasons. Each factor has a main effect, the pair has an interaction, and a design that is read as though the second factor were not there answers none of them. So the effect is planted in five shapes rather than one, chosen to make the three questions come apart: a shape whose main effects are real and whose interaction is not, and a shape whose interaction is real and whose main effects are exactly zero, are both here, and no single test tells them apart.
A list of five elements.
argsdata, feats, factors, factor_lv and input_scale,
and under a within design also within and id. factors is a named
list holding one vector per factor, each as long as data has rows, and
factor_lv is the level order of each, exactly as it was passed in.
These are the names the factorial comparison will take, so it will be
one do.call() away when it exists.
truthOne row per feature, aligned with feats, holding
features, pattern, spread, direction, partner,
extreme_cell, extreme_tied, log2fc, baseline and sd_subject.
truth_termOne row per feature and model term, every main effect
and every interaction of every order, holding features, terms,
term_order, is_within, max_abs_delta and is_effect. This is the
table that scores an ANOVA table row by row, and the one that has no
counterpart in simulate_multiple_groups().
truth_cellOne row per feature and cell, holding features, one
column per factor, then is_ref, delta, center, sd and n. A
feature the analysis missed can be looked up here rather than guessed
at: a large sd explains a miss that the effect size alone does not.
truth_contrastOne row per feature and pair of levels, in the row
order and direction a post-hoc table uses, holding features,
factor, stratum, contrast, group1, group2, delta and
is_diff. A stratum of NA is the marginal contrast, averaged over
the other factors; anything else names the combination of the other
factors the contrast was taken inside, which is the simple effect.
Each planted feature is given a magnitude d drawn from deg_log2fc,
positive for an up feature and negative for a down one, a shape from
term_mix, and for every shape but the first a partner factor drawn at random
from the factors after the primary one. The shape decides which terms of the
model end up carrying d.
"main_only"The primary factor moves and nothing else does. Every
other main effect and every interaction is exactly zero. This is
simulate_multiple_groups() inside a factorial frame, and the case a
two-way analysis should answer with one row.
"additive"The primary factor and the partner each move, and their interaction is exactly zero. The cell means are the sum of the two, so the profiles are parallel, and an interaction reported as significant here is a false positive by construction.
"interaction"The primary factor moves and the size of its effect depends on the level of the partner. The primary main effect and the interaction are both real; the partner's own main effect is left at exactly zero, so the two terms that should be called are the only two there are.
"crossover"Pure interaction. The primary factor rises at the partner's reference level and falls at the others, by amounts arranged so that both main effects are exactly zero while the cells plainly differ. This is the shape a main-effect test has to miss and an interaction test has to catch, which is the reason a factorial design is analysed as one.
"nuisance_only"The partner moves and the primary factor is exactly zero. Read as one factor, the primary factor looks null with inflated within-group spread; read as a factorial design, the spread is accounted for and belongs to a term of its own.
A feature that was not planted has a delta of exactly zero in every cell and a
component of exactly zero in every term. Both kinds of mistake are therefore
defined for every row of truth_term: a term called significant on a zero
component is a false positive, and a non-zero component that was not called is
a miss.
The defaults are set so that an analysis gets most of the answer and not all
of it, which is the band between a simulation that is trivially recovered and
one that looks broken. An aov(y ~ treatment * sex) on the default 4 by 2
design finds the treatment main effect about four times in five, which is the
rate simulate_multiple_groups() was tuned to, the two-level factor's main
effect rather more often than that, and the interaction between the two,
depending on which shape planted it. Every term that was not planted is called
at about a twentieth, which is what makes a false positive rate readable off
this simulation at all.
The rates are a function of n_per_cell as much as of the effect sizes, and
they fall away quickly below the default: at eight per cell the
"interaction" shape's interaction is no longer distinguishable from a null
term. A design small enough to be quick is not a design these numbers describe.
The effect is built in the space an ANOVA decomposes into: a main effect is a profile along its own factor that sums to zero, and an interaction sums to zero along each of its factors. The components are added up and the value at the reference cell is then subtracted from the whole array, which leaves the reference cell at exactly zero delta without touching any term, since a constant belongs to the grand mean alone.
That is why the two tables read differently and both are right.
truth_cell$delta is the shift of a cell from the reference cell, the way
truth_group$delta is in simulate_multiple_groups(), and for a
"main_only" feature the cell at level j of the primary factor carries
exactly what the pattern_mix profile put there. truth_term$max_abs_delta
is the largest component of the ANOVA effect itself, which is the quantity
that is exactly zero for a term that was not planted. Components smaller than
1e-8 in absolute value are recorded as exactly zero: they are the rounding
left over from averaging, and a term left holding 3e-17 would score every
row of an ANOVA table against the wrong answer.
direction is the sign of d, and it is the sign of the primary factor's
effect at the reference level of the partner. truth$log2fc is the delta of
whichever cell sits furthest from the reference cell, so for every shape but
"crossover" an up feature is positive there. Under "crossover" the primary
factor moves in opposite directions at different levels of the partner, so
which cell is furthest, and its sign, follow from the shape rather than from
direction.
truth_contrast$delta is group1 - group2 with group1 the later level of
the factor, which is the direction and the row order a post-hoc table uses. It
comes from sa_level_pairs(), the same helper the post-hoc tables are built
from, so the two cannot drift apart.
extreme_cell is the levels of that cell joined by a dot, so it reads back
against truth_cell without a lookup. When more than one cell is equally far
from the reference, which is what the "all" profile does on purpose, it
records the first of them and extreme_tied is TRUE: the flag that says to
score the magnitude rather than the name of the cell. It is TRUE with
extreme_cell missing for an unplanted feature, whose cells are all zero and
none of which is furthest.
A subject belongs to one combination of the between factors and is measured under every combination of the within ones, so no subject is dropped and the within-subject rectangle is complete. Each subject gets an offset per feature, drawn once and added to all of its rows, which is the between-subject variation a within-subject test removes. The residual standard deviation still differs from cell to cell, so sphericity does not hold and the corrections a repeated measures analysis reports have something to report.
simulate_multiple_groups() for the one-factor case, whose
pattern_mix shapes this reuses, and simulate_two_groups() for two
groups.
## A 4 x 2 design: four treatments crossed with sex, both between subjects. sim <- simulate_factorial_groups(n_feats = 20, n_up = 5, n_down = 5, n_per_cell = 6, seed = 1) table(pattern = sim$truth$pattern, direction = sim$truth$direction) ## The answer per term. A "crossover" feature has an interaction and no main ## effect at all, which is what makes the shape worth planting. cross <- sim$truth$features[sim$truth$pattern == "crossover"][1] subset(sim$truth_term, features == cross, select = c("terms", "max_abs_delta", "is_effect")) ## Scored against a two-way ANOVA. The term names line up with `truth_term`, ## so the two tables merge without being renamed. long <- data.frame(y = sim$args$data[[cross]], sim$args$factors) summary(stats::aov(y ~ treatment * sex, data = long)) ## An "additive" feature has both main effects and no interaction, so it is ## the shape that makes an interaction call a false positive. add <- sim$truth$features[sim$truth$pattern == "additive"][1] subset(sim$truth_term, features == add, select = c("terms", "is_effect")) ## A missed feature is looked up rather than guessed at. The row per cell ## carries the spread the cell was given, which is one of the reasons. head(subset(sim$truth_cell, features == cross), 4) ## Marginal contrasts and simple effects are both in `truth_contrast`. A ## stratum of NA is the marginal one. head(subset(sim$truth_contrast, features == cross & factor == "treatment"), 3) ## Time measured on the same subjects, treatment and sex between them: a ## mixed design. `args` then carries `id` and `within`. mixed <- simulate_factorial_groups( n_feats = 10, n_up = 2, n_down = 2, n_per_cell = 4, factor_lv = list(treatment = c("control", "treat_A", "treat_B"), sex = c("male", "female"), time = c("T0", "T1", "T2")), within = "time", seed = 1 ) names(mixed$args) ## Every subject under every time point, so the rectangle is complete. table(table(mixed$args$id)) ## Which terms are tested in the within-subject error stratum is recorded, so ## the answer table knows which half of a mixed ANOVA to score. unique(mixed$truth_term[c("terms", "term_order", "is_within")])## A 4 x 2 design: four treatments crossed with sex, both between subjects. sim <- simulate_factorial_groups(n_feats = 20, n_up = 5, n_down = 5, n_per_cell = 6, seed = 1) table(pattern = sim$truth$pattern, direction = sim$truth$direction) ## The answer per term. A "crossover" feature has an interaction and no main ## effect at all, which is what makes the shape worth planting. cross <- sim$truth$features[sim$truth$pattern == "crossover"][1] subset(sim$truth_term, features == cross, select = c("terms", "max_abs_delta", "is_effect")) ## Scored against a two-way ANOVA. The term names line up with `truth_term`, ## so the two tables merge without being renamed. long <- data.frame(y = sim$args$data[[cross]], sim$args$factors) summary(stats::aov(y ~ treatment * sex, data = long)) ## An "additive" feature has both main effects and no interaction, so it is ## the shape that makes an interaction call a false positive. add <- sim$truth$features[sim$truth$pattern == "additive"][1] subset(sim$truth_term, features == add, select = c("terms", "is_effect")) ## A missed feature is looked up rather than guessed at. The row per cell ## carries the spread the cell was given, which is one of the reasons. head(subset(sim$truth_cell, features == cross), 4) ## Marginal contrasts and simple effects are both in `truth_contrast`. A ## stratum of NA is the marginal one. head(subset(sim$truth_contrast, features == cross & factor == "treatment"), 3) ## Time measured on the same subjects, treatment and sex between them: a ## mixed design. `args` then carries `id` and `within`. mixed <- simulate_factorial_groups( n_feats = 10, n_up = 2, n_down = 2, n_per_cell = 4, factor_lv = list(treatment = c("control", "treat_A", "treat_B"), sex = c("male", "female"), time = c("T0", "T1", "T2")), within = "time", seed = 1 ) names(mixed$args) ## Every subject under every time point, so the rectangle is complete. table(table(mixed$args$id)) ## Which terms are tested in the within-subject error stratum is recorded, so ## the answer table knows which half of a mixed ANOVA to score. unique(mixed$truth_term[c("terms", "term_order", "is_within")])
The multi-group counterpart of simulate_two_groups(). Generates log2-scale
abundance data for one control group and any number of treatment groups, and
returns the planted answer alongside the data so that a comparison can be
scored against what was actually put there.
simulate_multiple_groups( n_feats = 100, n_control = 50, n_treat = rep(50, 3), n_up = round(0.15 * n_feats), n_down = round(0.15 * n_feats), pattern_mix = c(all = 1, gradient = 1, single = 1), expr_range = c(2, 12), control_sd = c(1.2, 2.4), treat_sd = c(1.8, 3.2), deg_log2fc = c(1, 2.5), paired = FALSE, subject_sd = c(2, 4), group_lv = NULL, feat_prefix = "prot", seed = NULL )simulate_multiple_groups( n_feats = 100, n_control = 50, n_treat = rep(50, 3), n_up = round(0.15 * n_feats), n_down = round(0.15 * n_feats), pattern_mix = c(all = 1, gradient = 1, single = 1), expr_range = c(2, 12), control_sd = c(1.2, 2.4), treat_sd = c(1.8, 3.2), deg_log2fc = c(1, 2.5), paired = FALSE, subject_sd = c(2, 4), group_lv = NULL, feat_prefix = "prot", seed = NULL )
n_feats |
Number of features to generate. Columns are named
|
n_control |
Number of observations in the control group. |
n_treat |
Observations in each treatment group, one entry per group, so
that its length is how many treatment groups there are. Pass
|
n_up, n_down
|
How many features are moved up and down in the treatment
groups. Their sum cannot exceed |
pattern_mix |
Named vector of relative weights over the three shapes an effect can take, described under "The three shapes" below. Set a weight to zero to leave that shape out. The planted features are split between the shapes in these proportions by the largest remainder method rather than drawn at random, so the counts are exactly what the weights ask for and do not move with the seed. |
expr_range |
Range the baseline log2 abundance of each feature is drawn from. Every level shares the baseline, which is what makes an unplanted feature null. |
control_sd, treat_sd
|
Ranges the per-feature standard deviation of the
control group and of each treatment group are drawn from. Every group draws
its own, so the design is heteroscedastic, which is the situation Welch's
ANOVA and Games-Howell exist for. Pass the same range twice for equal
variances. The defaults are the two ranges |
deg_log2fc |
Range the magnitude of the planted effect is drawn from, on
the log2 scale. This is the magnitude at the level that carries the full
effect; the |
paired |
Logical. If |
subject_sd |
Range the per-feature subject standard deviation is drawn
from. A subject's offset is drawn once per feature and reused across every
condition, which is what a within-subject test exists to remove. The
default is deliberately of the same order as the residual spread, so that
analysing the same table without |
group_lv |
Group labels, the first being the control that every effect
is planted against. Defaults to |
feat_prefix |
Prefix for the generated feature names. |
seed |
Seed for the draw, or |
With two groups there is one thing to get right: whether a feature moved. With three or more there are two, and they fail separately. The omnibus test asks whether the levels are all alike, and the post-hoc stage asks which of them differ. A feature can clear the first and be misread by the second, and a shape of effect that the omnibus finds easy can be the one the post-hoc stage finds hard. That is why the effect is planted in three shapes rather than one, and why the answer comes back in three tables rather than one.
A list of four elements.
argsdata, feats, group, group_lv and input_scale,
named after the arguments of compare_multiple_groups() so that
do.call(compare_multiple_groups, sim$args) runs the comparison. Under
paired = TRUE it also carries id and paired.
truthOne row per feature, aligned with feats, holding
features, pattern, direction, extreme_level, extreme_tied,
log2fc, baseline and sd_subject. This is the table that scores
$effect and the omnibus tests.
truth_groupOne row per feature and level, holding features,
group, is_ref, delta, center, sd and n. A feature the
comparison missed can be looked up here rather than guessed at: a large
sd explains a miss that the effect size alone does not.
truth_contrastOne row per feature and pair of levels, in the
row order and direction the post-hoc tables use, holding features,
contrast, group1, group2, delta and is_diff. This is the
table that scores $posthoc.
Each planted feature is given a magnitude d drawn from deg_log2fc,
positive for an up feature and negative for a down one, and one of three
shapes that decides what each treatment group does with it.
"all"Every treatment group is shifted by d. Only the control
stands apart, so the omnibus test has the whole effect to work with and
every contrast against the control should be found.
"gradient"Treatment group g of k is shifted by d * g / k,
so the last one carries the full effect and the ones before it carry a
fraction. This is the dose-response shape, and its early contrasts are
the ones a post-hoc stage loses first.
"single"One treatment group, chosen at random, is shifted by d
and the rest are left at exactly zero. The omnibus test is diluted here,
since most of the levels it compares are alike, so this is the shape it
misses most often. When it does clear the cutoff, exactly the contrasts
involving that one level should come back.
A feature that was not planted has a delta of exactly zero in every group. Both kinds of mistake are therefore defined: a contrast called significant on a zero delta is a false positive, and a non-zero delta that was not called is a miss.
truth$log2fc is the delta of whichever level sits furthest from the control,
which is the quantity $effect$log2fc estimates. A treatment group that went
up gives a positive value in both.
truth_contrast$delta reads the same way, because the post-hoc tables do. A
post-hoc estimate is group1 - group2 with group1 the later level of
group_lv, and the control is the first level, so the contrast is
treat_1 - control and a feature whose treatment groups went up is positive
there too. truth_contrast$delta is computed as the same difference, so
every direction in the three tables points one way.
Under the "all" shape every treatment group carries the same delta, so no
single level is furthest from the control. extreme_level then records the
first of the tied levels and extreme_tied is TRUE, which is the flag that
says to score the magnitude rather than the name of the level. It is also
TRUE, with extreme_level missing, for an unplanted feature.
Under paired = TRUE each subject is measured under every condition, so no
subject is dropped and design$unmatched_ids comes back empty. Each subject
gets an offset per feature, drawn once and added to all of its conditions,
which is the between-subject variation the within-subject tests remove. The
residual standard deviation still differs between conditions, so sphericity
does not hold and the Mauchly, Greenhouse-Geisser and Huynh-Feldt columns of
the repeated measures ANOVA have something to report.
The same subjects appearing under every condition also means every group
holds the same number of them, so n_control and every entry of n_treat
have to agree. Unequal sizes are rejected rather than quietly levelled,
since the sizes are the clearest statement of which design was meant.
compare_multiple_groups(), which consumes args directly,
simulate_two_groups() for the two-group case, and
estimate_significance() for the verdict that truth is there to score.
sim <- simulate_multiple_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 1) table(pattern = sim$truth$pattern, direction = sim$truth$direction) ## The names in `args` are compare_multiple_groups()'s own, so the comparison ## is one call away. res <- do.call(compare_multiple_groups, c(sim$args, diagnose = FALSE)) sig <- estimate_significance(res, test = "anova_test")$significance ## Scored against what was planted. The off-diagonal cells are the two kinds ## of mistake: features that were planted and missed, and null features that ## were called anyway. planted <- sim$truth$direction != "none" table(planted = planted, called = sig$is_signif %in% TRUE) ## The shape of the effect decides how hard the omnibus test finds it. A ## "single" feature differs from the control in one level out of several, so ## most of what the test compares is alike. tapply(sig$is_signif %in% TRUE, sim$truth$pattern, mean) ## The pairwise stage is scored against `truth_contrast`, which is already in ## the row order and the direction the post-hoc table uses. ph <- merge(res$posthoc$anova_test, sim$truth_contrast, by = c("features", "contrast")) table(differs = ph$is_diff, called = ph$pval_adj <= 0.05) ## A missed feature is looked up rather than guessed at. The row per level ## carries the size the group was given, which is one of the reasons. subset(sim$truth_group, features == sim$truth$features[1]) ## `n_treat` is one size per treatment group, so its length is how many there ## are. Five groups of unequal size, against a control of 40: uneven <- simulate_multiple_groups(n_feats = 20, n_control = 40, n_treat = c(30, 25, 20, 15, 10), seed = 1) table(uneven$args$group)[uneven$args$group_lv] ## Labels alone also say how many groups there are, so one size is spread ## over them. dose <- simulate_multiple_groups(n_feats = 20, n_treat = 25, group_lv = c("dmso", "low", "mid", "high"), seed = 1) table(dose$args$group)[dose$args$group_lv] ## Repeated conditions: the same subjects under every treatment, so every ## group holds the same number of them. rep_sim <- simulate_multiple_groups(n_feats = 10, n_up = 2, n_down = 2, n_control = 12, n_treat = rep(12, 3), paired = TRUE, seed = 1) rep_res <- do.call(compare_multiple_groups, c(rep_sim$args, diagnose = FALSE)) rep_res$tests$anova_test[1:3, c("features", "f_stat", "pval_adj")]sim <- simulate_multiple_groups(n_feats = 30, n_up = 5, n_down = 5, seed = 1) table(pattern = sim$truth$pattern, direction = sim$truth$direction) ## The names in `args` are compare_multiple_groups()'s own, so the comparison ## is one call away. res <- do.call(compare_multiple_groups, c(sim$args, diagnose = FALSE)) sig <- estimate_significance(res, test = "anova_test")$significance ## Scored against what was planted. The off-diagonal cells are the two kinds ## of mistake: features that were planted and missed, and null features that ## were called anyway. planted <- sim$truth$direction != "none" table(planted = planted, called = sig$is_signif %in% TRUE) ## The shape of the effect decides how hard the omnibus test finds it. A ## "single" feature differs from the control in one level out of several, so ## most of what the test compares is alike. tapply(sig$is_signif %in% TRUE, sim$truth$pattern, mean) ## The pairwise stage is scored against `truth_contrast`, which is already in ## the row order and the direction the post-hoc table uses. ph <- merge(res$posthoc$anova_test, sim$truth_contrast, by = c("features", "contrast")) table(differs = ph$is_diff, called = ph$pval_adj <= 0.05) ## A missed feature is looked up rather than guessed at. The row per level ## carries the size the group was given, which is one of the reasons. subset(sim$truth_group, features == sim$truth$features[1]) ## `n_treat` is one size per treatment group, so its length is how many there ## are. Five groups of unequal size, against a control of 40: uneven <- simulate_multiple_groups(n_feats = 20, n_control = 40, n_treat = c(30, 25, 20, 15, 10), seed = 1) table(uneven$args$group)[uneven$args$group_lv] ## Labels alone also say how many groups there are, so one size is spread ## over them. dose <- simulate_multiple_groups(n_feats = 20, n_treat = 25, group_lv = c("dmso", "low", "mid", "high"), seed = 1) table(dose$args$group)[dose$args$group_lv] ## Repeated conditions: the same subjects under every treatment, so every ## group holds the same number of them. rep_sim <- simulate_multiple_groups(n_feats = 10, n_up = 2, n_down = 2, n_control = 12, n_treat = rep(12, 3), paired = TRUE, seed = 1) rep_res <- do.call(compare_multiple_groups, c(rep_sim$args, diagnose = FALSE)) rep_res$tests$anova_test[1:3, c("features", "f_stat", "pval_adj")]
Generates a continuous outcome from a linear combination of predictors and returns the coefficients that were planted alongside the data, so that a fitted model can be scored against what was actually there. Everything a model has to survive can be asked for: predictors that correlate, a categorical predictor, a predictor that takes one value, missing cells, and repeated measurements of the same subject.
simulate_regression( n_samples = 200, n_pred = 8, n_pos = round(0.25 * n_pred), n_neg = round(0.25 * n_pred), beta = NULL, beta_range = c(0.5, 2), intercept = 0, value_mean = 0, value_sd = 1, noise_sd = 3, cor_mat = NULL, n_factor_pred = 1, factor_lv = c("low", "mid", "high"), n_constant_pred = 0, p_missing = 0, n_per_subject = NULL, subject_sd = 1, subject_share = 0.5, pred_prefix = "x", seed = NULL )simulate_regression( n_samples = 200, n_pred = 8, n_pos = round(0.25 * n_pred), n_neg = round(0.25 * n_pred), beta = NULL, beta_range = c(0.5, 2), intercept = 0, value_mean = 0, value_sd = 1, noise_sd = 3, cor_mat = NULL, n_factor_pred = 1, factor_lv = c("low", "mid", "high"), n_constant_pred = 0, p_missing = 0, n_per_subject = NULL, subject_sd = 1, subject_share = 0.5, pred_prefix = "x", seed = NULL )
n_samples |
Rows to generate. Ignored when |
n_pred |
Number of numeric predictors. Columns are named |
n_pos, n_neg
|
How many numeric predictors are given a positive and a
negative coefficient. Their sum cannot exceed |
beta |
The coefficients themselves, one per numeric predictor and no
intercept among them, or |
beta_range |
Range the magnitude of a planted coefficient is drawn from.
The offsets of the factor predictors are drawn from it too, so it is read
whether or not |
intercept |
The intercept. It is not part of |
value_mean, value_sd
|
Mean and standard deviation of each numeric
predictor, given once for all of them or once each. A coefficient is a change
in the outcome per unit of its predictor, so these fix what |
noise_sd |
Standard deviation of the residual noise. This and |
cor_mat |
Correlation matrix of the numeric predictors, as built by
|
n_factor_pred |
Number of categorical predictors, named |
factor_lv |
Levels of each categorical predictor, the first being the reference that carries no offset. |
n_constant_pred |
Number of predictors that take a single value, named
|
p_missing |
Proportion of numeric predictor cells to replace with |
n_per_subject |
Rows measured on each subject, one entry per subject, so
that its length is how many subjects there are. A single number is spread over
|
subject_sd |
Standard deviation of the per-subject offset on the outcome,
which is variation no predictor accounts for. Ignored without
|
subject_share |
Share of each numeric predictor's variance that lies
between subjects rather than within one, so its intraclass correlation. This
is what makes two rows of one subject resemble each other, and it is that
resemblance a row-wise split gives away: at |
pred_prefix |
Prefix for the generated predictor names. |
seed |
Seed for the draw, or |
The point of the exercise is the gap between the coefficient table and the truth, and the three things that open it. A planted coefficient can be too small for the noise to let it through, a null predictor correlated with a planted one is estimated away from zero however much data there is, and a subject measured repeatedly makes a row-wise split score the model on rows it half knows already. The defaults are set so that a run recovers most but not all of what was planted, because a simulation that recovers everything teaches none of this.
Each row is drawn as
y = intercept + sum(beta * x) + factor offsets + subject offset + noise,
with the numeric predictors drawn from a multivariate normal whose correlations
are cor_mat and the noise normal with standard deviation noise_sd. Because
the outcome is built from the coefficients and nothing else, a predictor whose
coefficient is zero is null in the strict sense, and a p-value below the cutoff
on one is a false positive by definition.
Which predictors carry a planted coefficient is drawn at random, but how many
carry a positive one and how many a negative one is not: n_pos and n_neg are
counts, so they do not move with the seed. beta states every coefficient
instead, in which case nothing is planted and its length is how many numeric
predictors there are.
A list of six elements.
argsdata, outcome and predictors, named after the arguments
of fit_linear_regression() so that
do.call(fit_linear_regression, sim$args) fits the model. predictors
is given explicitly rather than left to its NULL default, which would
take the subject column as a predictor and let the model fit on which
subject a row came from.
split_argsdata, stratified and id, named after the arguments
of split_data(). The outcome is the stratifier when there are no
subjects; with subjects it varies within a subject and so cannot stratify
a split taken over them, and the first categorical predictor, which is
drawn per subject, is used instead.
truthOne row per predictor, in the column order of data, holding
predictors, role ("signal", "null", "factor" or "constant"),
beta, direction, value_mean, value_sd and max_cor_signal, the
largest correlation this predictor has with a planted one. The last is
what accounts for a null predictor that came back significant.
truth_termOne row per model term, in the row order
coefficients follows, holding terms, the predictors each term came
from, and beta. This is the table that scores the coefficients, since a
categorical predictor is several terms and a constant one is none.
truth_modelThe model as a whole: intercept, noise_sd,
signal_var, subject_var and r_squared, the share of the variance of
the outcome the predictors account for, which is what fit_stats$r_squared
estimates. Also n_samples, n_subject and subject_sd.
truth_rowOne row per observation, holding subject,
subject_offset, eta (the whole linear predictor, intercept included)
and noise, so that y is exactly eta + noise.
Eight numeric predictors, four of them planted, one categorical predictor and
200 rows, with noise_sd = 3 against coefficients of 0.5 to 2 on unit-variance
predictors. Averaged over twenty seeds that leaves 47% of the variance of the
outcome accounted for by the predictors and recovers 0.83 of the planted
coefficients at p <= 0.05, which is the same difficulty
simulate_two_groups() is tuned to. Lowering noise_sd to 2 recovers 0.94 and
raising it to 5 costs a third of them.
The rate at which a null predictor is called is 0.05 and stays there, because a coefficient table applies no multiplicity adjustment across its terms. That is a property of the model rather than of these defaults, and it is one of the things a simulation with strictly null predictors is for: with eight predictors and a cutoff of 0.05, a table that names a predictor it should not is the expected outcome of roughly every other run.
fit_linear_regression(), which consumes args directly,
simulate_classification() for a two-class outcome, split_data(), which
consumes split_args, and make_block_cor() for cor_mat.
sim <- simulate_regression(seed = 1) sim$truth[, c("predictors", "role", "beta")] ## The names in `args` are fit_linear_regression()'s own, so the fit is one ## call away. fit <- do.call(fit_linear_regression, c(sim$args, cv = FALSE)) ## Scored against what was planted. `truth_term` is already in the row order ## the coefficient table follows, so the two line up without matching. scored <- merge(fit$coefficients, sim$truth_term, by = "terms") scored[, c("terms", "beta", "estimate", "pval")] ## Both kinds of mistake are defined, because a null predictor's coefficient ## is exactly zero rather than merely small. table(planted = scored$beta != 0, called = scored$pval <= 0.05) ## The model as a whole is scored too: r_squared estimates the share of the ## variance the predictors actually carry. c(planted = sim$truth_model$r_squared, fitted = fit$fit_stats$r_squared) ## Correlated predictors are the reason a coefficient table names the wrong ## one. `x_2` is null and correlates 0.9 with `x_1`, which is not. pair <- simulate_regression( n_pred = 4, beta = c(2, 0, 0, 0), cor_mat = make_block_cor(4, list(list(features = 1:2, cor = 0.9))), seed = 2 ) pair$truth[, c("predictors", "role", "beta", "max_cor_signal")] ## Three measurements per subject, so a split has to be taken over subjects. rep_sim <- simulate_regression(n_per_subject = rep(3, 40), seed = 3) sp <- do.call(split_data, c(rep_sim$split_args, seed = 1)) intersect(sp$datasets[[1]]$train_data$subject, sp$datasets[[1]]$test_data$subject)sim <- simulate_regression(seed = 1) sim$truth[, c("predictors", "role", "beta")] ## The names in `args` are fit_linear_regression()'s own, so the fit is one ## call away. fit <- do.call(fit_linear_regression, c(sim$args, cv = FALSE)) ## Scored against what was planted. `truth_term` is already in the row order ## the coefficient table follows, so the two line up without matching. scored <- merge(fit$coefficients, sim$truth_term, by = "terms") scored[, c("terms", "beta", "estimate", "pval")] ## Both kinds of mistake are defined, because a null predictor's coefficient ## is exactly zero rather than merely small. table(planted = scored$beta != 0, called = scored$pval <= 0.05) ## The model as a whole is scored too: r_squared estimates the share of the ## variance the predictors actually carry. c(planted = sim$truth_model$r_squared, fitted = fit$fit_stats$r_squared) ## Correlated predictors are the reason a coefficient table names the wrong ## one. `x_2` is null and correlates 0.9 with `x_1`, which is not. pair <- simulate_regression( n_pred = 4, beta = c(2, 0, 0, 0), cor_mat = make_block_cor(4, list(list(features = 1:2, cor = 0.9))), seed = 2 ) pair$truth[, c("predictors", "role", "beta", "max_cor_signal")] ## Three measurements per subject, so a split has to be taken over subjects. rep_sim <- simulate_regression(n_per_subject = rep(3, 40), seed = 3) sp <- do.call(split_data, c(rep_sim$split_args, seed = 1)) intersect(sp$datasets[[1]]$train_data$subject, sp$datasets[[1]]$test_data$subject)
Generates log2-scale expression data for two independent groups with a fixed number of features moved up and down on purpose, and returns the planted answer alongside the data. Every quantity a comparison estimates can then be checked against what was actually put there, which is what a real data set can never offer.
simulate_two_groups( n_feats = 100, n_case = 50, n_control = 50, n_up = 15, n_down = 15, expr_range = c(2, 12), case_sd = c(1.8, 3.2), control_sd = c(1.2, 2.4), deg_log2fc = c(1, 2.5), group_lv = c("control", "case"), seed = NULL )simulate_two_groups( n_feats = 100, n_case = 50, n_control = 50, n_up = 15, n_down = 15, expr_range = c(2, 12), case_sd = c(1.8, 3.2), control_sd = c(1.2, 2.4), deg_log2fc = c(1, 2.5), group_lv = c("control", "case"), seed = NULL )
n_feats |
Number of features to generate. Columns are named |
n_case, n_control
|
Observations in each group. They do not have to match. |
n_up, n_down
|
How many features are moved up and down in the case group.
Their sum cannot exceed |
expr_range |
Range the baseline log2 expression of each feature is drawn from. The default spans what log2 CPM or RMA values usually cover. Both groups share the baseline, which is what makes an unplanted feature null. |
case_sd, control_sd
|
Ranges the per-feature standard deviation of each group is drawn from. They are drawn independently, so the groups end up with unequal variances, which is the situation Welch's t-test and the Brunner-Munzel test exist for. Pass the same range twice for a homoscedastic data set. The defaults leave roughly four planted features in five recoverable at the default cutoffs; narrowing them recovers nearly everything and widening them costs recall quickly. |
deg_log2fc |
Range the magnitude of the planted effect is drawn from, on
the log2 scale. The default of |
group_lv |
The two group labels, the first being the control and the
second the one the effect is applied to. Passed straight through to the
returned arguments, so it also fixes the direction
|
seed |
Seed for the draw, or |
The point of the exercise is the gap. A comparison does not recover every
feature that was planted, and the reasons it misses them are the three things
worth understanding about a volcano plot: the p-value may not clear its
cutoff, the multiplicity adjustment may take it back, and the estimated
log2fc carries a sampling error of its own, so a feature planted just above
the magnitude cutoff lands below it about half the time. The defaults are set
so that a run recovers most but not all of what was planted, because a
simulation that recovers everything teaches none of this.
A list of two elements.
argsdata, feats, group, group_lv and input_scale,
named after the arguments of compare_two_groups() so that
do.call(compare_two_groups, sim$args) runs the comparison.
input_scale is "log2", since that is the scale the data is on.
truthOne row per feature, aligned with feats, holding
features, direction ("up", "down" or "none"), log2fc (the
effect that was planted, exactly 0 for "none"), baseline and the
two group standard deviations. The last three are there so that a
feature the comparison missed can be looked up rather than guessed at:
a large sd_case explains a miss that the effect size alone does not.
Each feature gets a baseline b drawn from expr_range, two standard
deviations drawn from case_sd and control_sd, and a planted effect d
that is a positive draw from deg_log2fc when the feature is one of the
n_up, a negative draw when it is one of the n_down, and 0 otherwise.
Case observations are then normal around b + d and control observations
normal around b.
Because the baseline is shared, the true log2 fold change of a feature is d
and nothing else. An unplanted feature is null in the strict sense, so a
feature called significant is a false positive by definition and the
multiplicity adjustment can be judged on it. A model that gave each group its
own random offset would look more lifelike and would make both the recall and
the false positive rate impossible to compute, since an unplanted feature
would then differ between the groups too.
The data is on the log2 scale throughout, so input_scale = "log2" comes
back with it. The effect is added rather than multiplied, which is what makes
deg_log2fc a difference of log2 means rather than a ratio.
compare_two_groups(), which consumes args directly, and
estimate_significance() for the verdict that truth is there to score.
sim <- simulate_two_groups(seed = 1) table(sim$truth$direction) ## The names in `args` are compare_two_groups()'s own, so the comparison is ## one call away. res <- do.call(compare_two_groups, sim$args) sig <- estimate_significance(res, test = "t_test")$significance ## Scored against what was planted. The off-diagonal cells are the two kinds ## of mistake: features that were planted and missed, and null features that ## were called anyway. planted <- sim$truth$direction != "none" called <- sig$is_signif %in% TRUE table(planted = planted, called = called) ## The direction is recovered too, not just the fact of a difference. table(truth = sim$truth$direction[called], sign = sign(sig$log2fc[called])) ## Recall differs between the three families on the same data and the same ## truth, which is the reason all three are reported. vapply(names(res$tests), function(nm) { hit <- estimate_significance(res, test = nm)$significance$is_signif mean(hit[planted] %in% TRUE) }, numeric(1)) ## Turning the noise up costs recall without changing what was planted. noisy <- simulate_two_groups(seed = 1, case_sd = c(4, 6), control_sd = c(4, 6)) noisy_sig <- estimate_significance( do.call(compare_two_groups, noisy$args) )$significance mean(noisy_sig$is_signif[noisy$truth$direction != "none"] %in% TRUE)sim <- simulate_two_groups(seed = 1) table(sim$truth$direction) ## The names in `args` are compare_two_groups()'s own, so the comparison is ## one call away. res <- do.call(compare_two_groups, sim$args) sig <- estimate_significance(res, test = "t_test")$significance ## Scored against what was planted. The off-diagonal cells are the two kinds ## of mistake: features that were planted and missed, and null features that ## were called anyway. planted <- sim$truth$direction != "none" called <- sig$is_signif %in% TRUE table(planted = planted, called = called) ## The direction is recovered too, not just the fact of a difference. table(truth = sim$truth$direction[called], sign = sign(sig$log2fc[called])) ## Recall differs between the three families on the same data and the same ## truth, which is the reason all three are reported. vapply(names(res$tests), function(nm) { hit <- estimate_significance(res, test = nm)$significance$is_signif mean(hit[planted] %in% TRUE) }, numeric(1)) ## Turning the noise up costs recall without changing what was planted. noisy <- simulate_two_groups(seed = 1, case_sd = c(4, 6), control_sd = c(4, 6)) noisy_sig <- estimate_significance( do.call(compare_two_groups, noisy$args) )$significance mean(noisy_sig$is_signif[noisy$truth$direction != "none"] %in% TRUE)
Partitions the rows of a data set into a training half and a test half, optionally several times over. The partition is stratified, so the balance of the whole data set is preserved in both halves rather than left to the draw, and it can be taken over sampling units rather than over rows, so that repeated measurements of one subject never end up on both sides.
split_data( data, stratified = NULL, id = NULL, p_train = 0.75, times = 1, seed = NULL )split_data( data, stratified = NULL, id = NULL, p_train = 0.75, times = 1, seed = NULL )
data |
A data.frame (or matrix) in wide format, one row per observation. |
stratified |
What to preserve the balance of, either the name of a
column of |
id |
Sampling unit, either a column name or a vector with one entry per
row. Rows sharing a value are assigned to the same side of the split, which
is what keeps repeated measurements or technical replicates of one subject
from appearing in both halves. |
p_train |
Proportion allocated to the training set, strictly between 0 and 1. |
times |
Number of independent splits to draw. The shape of the result does not depend on it: one split still comes back as a list of one. |
seed |
Seed for the draw, or |
Nothing is fitted or transformed here. The point of splitting first is that every later step — imputation, scaling, feature selection, hyperparameter tuning — is fitted on the training half alone, and this function is what makes that half well defined.
The partition itself is drawn by caret::createDataPartition(), which takes
ceiling(n * p_train) observations from within each stratum. What it does
with stratified depends on its type, and the difference is worth knowing
because it is invisible from the outside:
Each distinct value is a stratum. A value
occurring only once is put into the training set and the test set has
none of it, which createDataPartition() warns about.
Cut into up to five quantile bins first, and the bins are the strata. This is how a continuous outcome is kept from landing entirely on one side, but it does mean a numeric stratifier is never matched exactly.
NULLNo strata. The split is a simple random draw of
ceiling(nrow(data) * p_train) rows.
With id the whole thing moves up one level. Rows are folded into units,
each unit takes the stratum its rows agree on, the partition is drawn over
units, and the chosen units are expanded back into row indices. p_train is
then a proportion of units, not of rows, and the two differ whenever the
units have unequal sizes; parameters$achieved_p reports the row proportion
each repeat actually reached.
An object of class sa_split, a plain list of five elements.
full_dataThe input, exactly as it was passed in. Held once rather than once per repeat.
datasetsOne element per repeat, named Resample1 upwards, each
a list of train_data, test_data, and the train_rows and
test_rows they were taken from. The two frames have their row names
reset, so the row numbers are the only record of where a row came from
and they are kept beside it.
train_idxThe training row indices of every repeat, in the form
caret::createDataPartition(list = TRUE) returns. A matrix is not
offered because units of unequal size make the repeats different
lengths.
designWhat the split was made on: row and unit counts, the
labels of stratified and id, and the number of units per stratum
where the strata are discrete.
parametersp_train, times, seed, and achieved_p, the row
proportion each repeat actually reached.
caret::createDataPartition(), which draws the partition.
## Stratified on the outcome: both halves keep the species balance of the ## whole data set rather than whatever the draw happened to give. sp <- split_data(iris, stratified = "Species", seed = 1) sp table(sp$datasets[[1]]$train_data$Species) table(sp$datasets[[1]]$test_data$Species) ## Three splits at once, for a repeated hold-out. sp3 <- split_data(iris, stratified = "Species", times = 3, seed = 1) vapply(sp3$datasets, function(d) nrow(d$train_data), numeric(1)) ## Three measurements per subject. Splitting by row would put most subjects ## in both halves; splitting by `id` cannot. rep_data <- data.frame( subject = rep(paste0("s", 1:20), each = 3), arm = rep(c("control", "treated"), each = 30), value = seq_len(60) ) sp_id <- split_data(rep_data, stratified = "arm", id = "subject", seed = 1) intersect(sp_id$datasets[[1]]$train_data$subject, sp_id$datasets[[1]]$test_data$subject) ## `p_train` is a proportion of units once `id` is given, so the row ## proportion it reaches is reported rather than assumed. sp_id$parameters$achieved_p## Stratified on the outcome: both halves keep the species balance of the ## whole data set rather than whatever the draw happened to give. sp <- split_data(iris, stratified = "Species", seed = 1) sp table(sp$datasets[[1]]$train_data$Species) table(sp$datasets[[1]]$test_data$Species) ## Three splits at once, for a repeated hold-out. sp3 <- split_data(iris, stratified = "Species", times = 3, seed = 1) vapply(sp3$datasets, function(d) nrow(d$train_data), numeric(1)) ## Three measurements per subject. Splitting by row would put most subjects ## in both halves; splitting by `id` cannot. rep_data <- data.frame( subject = rep(paste0("s", 1:20), each = 3), arm = rep(c("control", "treated"), each = 30), value = seq_len(60) ) sp_id <- split_data(rep_data, stratified = "arm", id = "subject", seed = 1) intersect(sp_id$datasets[[1]]$train_data$subject, sp_id$datasets[[1]]$test_data$subject) ## `p_train` is a proportion of units once `id` is given, so the row ## proportion it reaches is reported rather than assumed. sp_id$parameters$achieved_p
Reduces a set of features to the association between each pair of them, as a square matrix per quantity: the coefficient, its p-value, the p-value adjusted across the pairs, and how many observations the pair shared. Pearson, Spearman and Kendall are reported side by side on the same pairs, the way the comparison functions report a parametric, a rank-based and a robust test side by side, so that a linear coefficient and a monotonic one disagreeing is visible rather than a matter of which call was made.
summarize_association_stats( data, feats = NULL, methods = c("pearson", "spearman", "kendall"), adj_type = "BH", use = c("pairwise.complete.obs", "complete.obs") )summarize_association_stats( data, feats = NULL, methods = c("pearson", "spearman", "kendall"), adj_type = "BH", use = c("pairwise.complete.obs", "complete.obs") )
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
methods |
Which coefficients to compute, drawn from |
adj_type |
Multiplicity adjustment applied across the pairs, any method from stats::p.adjust.methods. |
use |
How a missing value is handled. |
This is a screen rather than a test of one hypothesis. It is the companion of
summarize_descriptive_stats(), which reduces one feature at a time to a row
of its own; this reduces a pair at a time. Neither returns a sa_comparison.
Every matrix is symmetric. The upper triangle is computed and mirrored, so a pair is tested once rather than twice.
The diagonal is a property of the matrix rather than an estimate: corr is 1
and pvalue and adj_pvalue are NA, since a feature is not tested against
itself. n on the diagonal is the number of observations that feature has.
The adjustment runs over the pairs that produced a p-value. A pair
stats::cor.test() refused, having fewer than three shared observations or no
variance on one side, is not a test that was performed, and counting it into
the family would shrink the other pairs for a comparison that never happened.
Such a pair comes back NA in all three of corr, pvalue and adj_pvalue
rather than aborting the screen, and features with no variance at all are
named in a message.
Missing and non-finite values are treated alike, an Inf being as much "no
value to correlate" as an NA, and n counts what was left.
The cost is one stats::cor.test() per pair per method: thirty features are
435 pairs, and 1305 tests with all three methods asked for. Kendall's is the
slowest of the three, so naming methods is worth doing on a wide frame.
A plain list. One slot per entry of methods, named after it and
holding four features-by-features matrices with the same dimnames:
corrThe coefficient, from stats::cor().
pvalueThe two-sided p-value from stats::cor.test().
adj_pvaluepvalue adjusted by adj_type across the pairs.
nObservations the pair shared.
Beside those, design records feats, n_obs, methods, adj_type and
use.
draw_corrplot() to draw a method's corr with the non-significant
cells left blank, and summarize_descriptive_stats() for the one-feature-at-a-time
summary this is the pairwise counterpart of.
feats <- c("mpg", "disp", "hp", "wt") ## All three coefficients on the same pairs res <- summarize_association_stats(mtcars, feats) round(res$pearson$corr, 3) round(res$pearson$adj_pvalue, 4) ## A linear coefficient and a monotonic one on the same pair c(pearson = res$pearson$corr["mpg", "disp"], spearman = res$spearman$corr["mpg", "disp"]) ## One method only rho <- summarize_association_stats(mtcars, feats, methods = "spearman") round(rho$spearman$corr, 3)feats <- c("mpg", "disp", "hp", "wt") ## All three coefficients on the same pairs res <- summarize_association_stats(mtcars, feats) round(res$pearson$corr, 3) round(res$pearson$adj_pvalue, 4) ## A linear coefficient and a monotonic one on the same pair c(pearson = res$pearson$corr["mpg", "disp"], spearman = res$spearman$corr["mpg", "disp"]) ## One method only rho <- summarize_association_stats(mtcars, feats, methods = "spearman") round(rho$spearman$corr, 3)
Reduces every feature to one row of sample size, central tendency, dispersion, quartiles, outlier fences and distribution shape. With a grouping vector the same row is produced per group level, so the summary lines up with the tests and plots that compare those levels.
summarize_descriptive_stats(data, feats, group = NULL, group_lv = NULL)summarize_descriptive_stats(data, feats, group = NULL, group_lv = NULL)
data |
A data.frame (or matrix) in wide format, one row per observation and one column per feature. |
feats |
Character vector of numeric column names in |
group |
Optional grouping vector with one entry per row of |
group_lv |
Group levels to report, in output order. When |
Missing and non-finite values are dropped per feature and per group before
anything is computed, so one Inf cannot turn a whole row into Inf or
NaN; n_missing records how many were left out. A feature with no finite
value in a group gives an all-NA row rather than aborting the summary.
skewness and excess_kurtosis are the bias-corrected G1 and G2
estimators used by SAS and SPSS, the same quantities as
e1071::skewness(type = 2) and e1071::kurtosis(type = 2). They need three
and four observations respectively, and a non-zero spread, and are NA
otherwise.
cv is a ratio, so it only reads as relative dispersion when the values are
positive. On data that crosses zero the mean shrinks towards it and the
ratio explodes without the spread having changed.
out_lower_bound and out_upper_bound are the same fences that
draw_grouped_boxplot() returns as lower_bound and upper_bound in
box_summary_stats. They are where the whiskers may reach, not where they
actually end.
A data.frame with one row per feature, or per feature and group
level when group is supplied. The leading columns are features and,
when grouped, group. The remaining columns are:
n, n_missing
Finite values the row is based on, and the
values left out for being NA, NaN or infinite.
mean, sd, var, se, cv
Mean, standard deviation,
variance, standard error sd / sqrt(n) and coefficient of variation
sd / mean.
min, q1, median, q3, max, iqr
Five-number summary and
the interquartile range. Quartiles come from stats::quantile() with
its default type 7.
out_lower_bound, out_upper_bound
Tukey outlier fences
q1 - 1.5 * iqr and q3 + 1.5 * iqr.
madMedian absolute deviation from stats::mad(), scaled by
the default 1.4826 so that it estimates sd for normal data.
skewness, excess_kurtosis
Shape of the distribution, zero for a normal sample.
Joanes, D. N. and Gill, C. A. (1998). Comparing measures of sample skewness and kurtosis. Journal of the Royal Statistical Society: Series D, 47(1), 183-189.
draw_grouped_boxplot() to see the same quantities as a plot, and
compare_two_groups() to test the difference between two levels.
feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## All rows together summarize_descriptive_stats(iris, feats) ## One row per feature and species by_species <- summarize_descriptive_stats(iris, feats, iris$Species) by_species[by_species$features == "Petal.Length", ] ## Two of the three species, in a chosen order summarize_descriptive_stats(iris, "Petal.Length", iris$Species, c("virginica", "versicolor"))feats <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width") ## All rows together summarize_descriptive_stats(iris, feats) ## One row per feature and species by_species <- summarize_descriptive_stats(iris, feats, iris$Species) by_species[by_species$features == "Petal.Length", ] ## Two of the three species, in a chosen order summarize_descriptive_stats(iris, "Petal.Length", iris$Species, c("virginica", "versicolor"))