Nothing
#' Plot manifest and fitted raw scores
#'
#' The function plots the raw data against the fitted scores from
#' the regression model per group. This helps to inspect the precision
#' of the modeling process. The scores should not deviate too far from
#' regression line.
#' @param model The regression model from the 'cnorm' function
#' @param group Should the fit be displayed by group?
#' @param type Type of display: 0 = plot manifest against fitted values, 1 = plot
#' manifest against difference values
#' @examples
#' \dontrun{
#' # Compute model with example dataset and plot results
#' result <- cnorm(raw = elfe$raw, group = elfe$group)
#' plotRaw(result)
#' }
#' @import ggplot2
#' @export
#' @family plot
plotRaw <- function(model, group = FALSE, type = 0) {
if (isParametric(model)) {
stop("This function is not applicable for parametric models.")
}
if (!isTaylor(model)) {
stop("Please provide a cnorm object.")
}
d <- model$data
model <- model$model
d$fitted <- model$fitted.values
d$diff <- d$fitted - d$raw
mse <- round(model$rmse, digits = 4)
r <- round(cor(d$fitted, d$raw, use = "pairwise.complete.obs"), digits = 4)
d <- as.data.frame(d)
if (group) {
if ("group" %in% colnames(d)) {
d$group <- as.factor(d$group)
} else {
d$group <- as.factor(getGroups(d$age))
}
}
if (type == 0) {
p <- ggplot(d, aes(x = .data$raw, y = .data$fitted)) +
geom_point(alpha = 0.2, color = "#0033AA") +
geom_abline(
intercept = 0,
slope = 1,
color = "red",
linetype = "dashed"
) +
labs(
title = if (isTRUE(group))
"Observed vs. Fitted Raw Scores by Group"
else
"Observed vs. Fitted Raw Scores",
subtitle = paste("r =", r, ", RMSE =", mse),
x = "Observed Score",
y = "Fitted Scores"
)
} else {
p <- ggplot(d, aes(x = .data$raw, y = .data$diff)) +
geom_point(alpha = 0.2, color = "#0033AA") +
geom_hline(yintercept = 0,
color = "red",
linetype = "dashed") +
labs(
title = if (isTRUE(group))
"Observed Raw Scores vs. Difference Scores by Group"
else
"Observed Raw Scores vs. Difference Scores",
subtitle = paste("r =", r, ", RMSE =", mse),
x = "Observed Score",
y = "Difference Scores"
)
}
if (group) {
p <- p + facet_wrap( ~ group)
}
p <- p + theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
plot.subtitle = element_text(hjust = 0.5, size = 12),
axis.title = element_text(size = 12, face = "bold"),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 10),
legend.position = "right",
legend.title = element_text(size = 12, face = "bold"),
legend.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
return(p)
}
#' @title Plot manifest and fitted norm scores
#'
#' @description
#' This function plots the manifest norm score against the fitted norm score from
#' the inverse regression model per group. This helps to inspect the precision
#' of the modeling process. The scores should not deviate too far from
#' the regression line. Applicable for Taylor polynomial, beta-binomial, and shash models.
#'
#' @param model The regression model, usually from the 'cnorm', 'cnorm.betabinomial', or 'cnorm.shash' function
#' @param age In case of parametric models, please provide the age vector
#' @param score In case of parametric models, please provide the score vector
#' @param width In case of parametric models, please provide the width for the sliding window.
#' If null, the function tries to determine a sensible setting.
#' @param weights Vector or variable name in the dataset with weights for each
#' individual case. If NULL, no weights are used.
#' @param group On optional grouping variable, use empty string for no group, the variable name
#' for Taylor polynomial models or a vector with the groups for parametric models
#' @param minNorm lower bound of fitted norm scores
#' @param maxNorm upper bound of fitted norm scores
#' @param type Type of display: 0 = plot manifest against fitted values, 1 = plot
#' manifest against difference values
#'
#' @return A ggplot object representing the norm scores plot.
#'
#' @examples
#' \dontrun{
#' # Load example data set, compute model and plot results
#'
#' # Taylor polynomial model
#' model <- cnorm(raw = elfe$raw, group = elfe$group)
#' plot(model, "norm")
#'
#' # Beta binomial models; maximum number of items in elfe is n = 28
#' model.bb <- cnorm.betabinomial(elfe$group, elfe$raw, n = 28)
#' plotNorm(model.bb, age = elfe$group, score = elfe$raw)
#' }
#'
#' @import ggplot2
#' @export
#' @family plot
plotNorm <- function(model,
age = NULL,
score = NULL,
width = NULL,
weights = NULL,
group = FALSE,
minNorm = NULL,
maxNorm = NULL,
type = 0) {
is_taylor <- isTaylor(model)
if (is_taylor) {
data <- model$data
model <- model$model
if (is.null(minNorm)) {
minNorm <- model$minL1
}
if (is.null(maxNorm)) {
maxNorm <- model$maxL1
}
d <- data
raw <- data[[model$raw]]
if (attr(data, "useAge"))
age <- data[[model$age]]
else
age <- rep(0, length = nrow(data))
d$fitted <- predictNorm(raw, age, model, minNorm = minNorm, maxNorm = maxNorm)
if (group) {
if ("group" %in% colnames(d)) {
d$group <- as.factor(d$group)
} else {
d$group <- as.factor(getGroups(d$age))
}
}
} else if (isParametric(model)) {
if (is.null(age) || is.null(score)) {
stop(
"Please provide age and score vectors for beta-binomial or shash models and the width for the sliding window."
)
}
# Extract model scale ensuring manifest norm scores match fitted norm scores
scaleMean <- attr(model$result, "scaleMean")
scaleSD <- attr(model$result, "scaleSD")
model_scale <- c(scaleMean, scaleSD)
d <- data.frame(age = age, score = score)
if (is.null(width)) {
if (length(age) / length(unique(age)) < 50)
stop("Please provide a width for the sliding window.")
d$group <- d$age
if (is.null(weights))
d <- rankByGroup(data = d,
group = "age",
raw = "score",
scale = model_scale)
else
d <- rankByGroup(
data = d,
group = "age",
raw = "score",
weights = weights,
scale = model_scale
)
} else{
if (is.null(weights))
d <- rankBySlidingWindow(
data = d,
age = "age",
raw = "score",
width = width,
scale = model_scale
)
else
d <- rankBySlidingWindow(
data = d,
age = "age",
raw = "score",
weights = weights,
width = width,
scale = model_scale
)
}
# Generic S3 dispatch so predict.cnormShash OR predict.cnormBetaBinomial is correctly used
d$fitted <- predict(model, d$age, d$score)
} else {
stop("Please provide an object of type cnorm, cnormBetaBinomial, cnormBetaBinomial2, or cnormShash.")
}
if (!"normValue" %in% colnames(d)) {
stop(
"The 'normValue' column is missing from the data. Please ensure it's present for both cnorm and parametric models."
)
}
d$diff <- d$fitted - d$normValue
d <- d[!is.na(d$fitted) & !is.na(d$diff), ]
rmse <- round(sqrt(mean(d$diff^2)), digits = 4)
r <- round(cor(d$fitted, d$normValue, use = "pairwise.complete.obs"),
digits = 4)
if (type == 0) {
if (is_taylor) {
title <- if (isTRUE(group) ||
(is.character(group) &&
nzchar(group)))
paste("Observed vs. Fitted Norm Scores by", group)
else
"Observed vs. Fitted Norm Scores"
} else{
title <- if (is.numeric(group))
paste("Observed vs. Fitted Norm Scores by group")
else
"Observed vs. Fitted Norm Scores"
}
p <- ggplot(d, aes(x = .data$normValue, y = .data$fitted)) +
geom_point(alpha = 0.2, color = "#0033AA") +
geom_abline(
intercept = 0,
slope = 1,
color = "red",
linetype = "dashed"
) +
labs(
title = title,
subtitle = paste("r =", r, ", RMSE =", rmse),
x = "Observed Scores",
y = "Fitted Scores"
)
} else {
if (is_taylor) {
title <- if (group != "" &&
!is.null(group))
paste("Observed Norm Scores vs. Difference Scores by", group)
else
"Observed Norm Scores vs. Difference Scores"
} else{
title <- if (is.numeric(group))
paste("Observed Norm Scores vs. Difference Scores by group")
else
"Observed Norm Scores vs. Difference Scores"
}
p <- ggplot(d, aes(x = .data$normValue, y = .data$diff)) +
geom_point(alpha = 0.5, color = "#0033AA") +
geom_hline(yintercept = 0,
color = "red",
linetype = "dashed") +
labs(
title = title,
subtitle = paste("r =", r, ", RMSE =", rmse),
x = "Observed Scores",
y = "Difference"
)
}
if (isTRUE(group) || (is.character(group) && nzchar(group))) {
p <- p + facet_wrap( ~ group)
}
p <- p + theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
plot.subtitle = element_text(hjust = 0.5, size = 12),
axis.title = element_text(size = 12, face = "bold"),
axis.text = element_text(size = 10),
legend.position = "right",
legend.title = element_text(size = 12, face = "bold"),
legend.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
return(p)
}
#' @import ggplot2
#' @export
#' @family plot
#'
#' @title Plot norm curves
#'
#' @description
#' This function plots the norm curves based on the regression model. It supports both
#' Taylor polynomial models, beta-binomial models, and shash models.
#'
#' @param model The model from the bestModel function, a cnorm object, or a cnormBetaBinomial / cnormBetaBinomial2 / cnormShash object.
#' @param normList Vector with norm scores to display. If NULL, default values are used.
#' @param minAge Age to start with checking. If NULL, it's automatically determined from the model.
#' @param maxAge Upper end of the age check. If NULL, it's automatically determined from the model.
#' @param step Stepping parameter for the age check, usually 1 or 0.1; lower scores indicate higher precision.
#' @param minRaw Lower end of the raw score range, used for clipping implausible results. If NULL, it's automatically determined from the model.
#' @param maxRaw Upper end of the raw score range, used for clipping implausible results. If NULL, it's automatically determined from the model.
#'
#' @details
#' Please check the function for inconsistent curves: The different curves should not intersect.
#' Violations of this assumption are a strong indication of violations of model assumptions in
#' modeling the relationship between raw and norm scores.
#'
#' Common reasons for inconsistencies include:
#' 1. Vertical extrapolation: Choosing extreme norm scores (e.g., scores <= -3 or >= 3).
#' 2. Horizontal extrapolation: Using the model scores outside the original dataset.
#' 3. The data cannot be modeled with the current approach, or you need another power parameter (k) or R2 for the model.
#'
#' @return A ggplot object representing the norm curves.
#'
#' @seealso \code{\link{checkConsistency}}, \code{\link{plotDerivative}}, \code{\link{plotPercentiles}}
#'
#' @examples
#' \dontrun{
#' # For Taylor continuous norming model
#' m <- cnorm(raw = ppvt$raw, group = ppvt$group)
#' plotNormCurves(m, minAge=2, maxAge=5)
#'
#' # For beta-binomial model
#' bb_model <- cnorm.betabinomial(age = ppvt$age, score = ppvt$raw, n = 228)
#' plotNormCurves(bb_model)
#' }
plotNormCurves <- function(model,
normList = NULL,
minAge = NULL,
maxAge = NULL,
step = 0.1,
minRaw = NULL,
maxRaw = NULL) {
if (isTaylor(model)) {
model <- model$model
}
parametric <- isParametric(model)
is_beta_binomial <- isBeta(model)
is_shash <- isSHASH(model)
if (!parametric && !model$useAge) {
stop("Age or group variable explicitly set to FALSE in dataset. No plotting available.")
}
# Get scale information
if (parametric) {
scaleMean <- attr(model$result, "scaleMean")
scaleSD <- attr(model$result, "scaleSD")
} else {
scaleMean <- model$scaleM
scaleSD <- model$scaleSD
}
if (is.null(normList)) {
normList <- c(-2, -1, 0, 1, 2) * scaleSD + scaleMean
}
if (is.null(minAge)) {
minAge <- if (parametric)
attr(model$result, "age_mean") - 2 * attr(model$result, "age_sd")
else
model$minA1
}
if (is.null(maxAge)) {
maxAge <- if (parametric)
attr(model$result, "age_mean") + 2 * attr(model$result, "age_sd")
else
model$maxA1
}
if (is.null(minRaw)) {
minRaw <- if (is_beta_binomial)
0
else if (is_shash)
attr(model$result, "min")
else
model$minRaw
}
if (is.null(maxRaw)) {
maxRaw <- if (parametric)
attr(model$result, "max")
else
model$maxRaw
}
frame_list <- lapply(normList, function(norm) {
if (parametric) {
ages <- seq(minAge, maxAge, by = step)
p_val <- pnorm((norm - scaleMean) / scaleSD)
if (is_beta_binomial) {
# Loop for backwards compat with un-vectorized underlying coefficient variants
raws <- sapply(ages, function(a) {
if (inherits(model, "cnormBetaBinomial")) {
pred <- predictCoefficients(model, a)
} else {
pred <- predictCoefficients2(model, a, attr(model$result, "max"))
}
qbeta(p_val, pred$a, pred$b) * attr(model$result, "max")
})
} else if (is_shash) {
# Predict parameters vectorized and calculate exact inverse quantiles
preds <- predictCoefficients_shash(model, ages)
raws <- qshash(p_val, mu = preds$mu, sigma = preds$sigma, epsilon = preds$epsilon, delta = preds$delta)
}
data.frame(n = norm, raw = raws, age = ages)
} else {
normCurve <- getNormCurve(
norm,
model,
minAge = minAge,
maxAge = maxAge,
step = step,
minRaw = minRaw,
maxRaw = maxRaw
)
data.frame(n = norm,
raw = normCurve$raw,
age = normCurve$age)
}
})
valueList <- do.call(rbind, frame_list)
# Create rainbow color palette
n_colors <- length(unique(valueList$n))
color_palette <- rainbow(n_colors)
# Create ggplot
p <- ggplot(valueList, aes(
x = .data$age,
y = .data$raw,
color = factor(.data$n)
)) +
geom_line(linewidth = 1) +
scale_color_manual(
name = "Norm Score",
values = color_palette,
labels = paste("Norm", normList)
) +
labs(title = "Norm Curves", x = "Explanatory Variable (Age)", y = "Raw Score") +
theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
axis.title = element_text(size = 12, face = "bold"),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 10),
legend.position = "right",
legend.title = element_text(size = 12, face = "bold"),
legend.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
return(p)
}
#' Cumulative norm distribution plot for conventional-norming models
#'
#' Used internally when `plotPercentiles()` is called on a cnorm object that
#' was built without age/group (i.e. `useAge == FALSE`). Plots raw score on
#' the x-axis against the model-implied percentile on the y-axis, with the
#' empirical cumulative distribution of the manifest sample overlaid.
#'
#' @param x A cnorm object.
#' @param minRaw,maxRaw Plot range on the raw-score axis. Defaults to the
#' model's stored raw range.
#' @param title,subtitle Plot annotations; defaults match `plotPercentiles`.
#' @noRd
plotCumulative <- function(x,
minRaw = NULL,
maxRaw = NULL,
title = NULL,
subtitle = NULL) {
if (!isTaylor(x))
stop("plotCumulative requires a cnorm object.")
data <- x$data
m <- x$model
raw_var <- m$raw
scaleM <- m$scaleM
scaleSD <- m$scaleSD
if (is.null(minRaw))
minRaw <- m$minRaw
if (is.null(maxRaw))
maxRaw <- m$maxRaw
curve_df <- rawTable(0, x, minRaw, maxRaw, pretty = F)
raw_obs <- data[[raw_var]]
raw_obs <- raw_obs[!is.na(raw_obs)]
n_obs <- length(raw_obs)
unique_raw <- sort(unique(raw_obs))
manifest_df <- data.frame(raw = unique_raw,
percentile = vapply(unique_raw, function(r) {
(sum(raw_obs < r) + 0.5 * sum(raw_obs == r)) / n_obs * 100
}, numeric(1)))
if (is.null(title)) {
title <- "Cumulative Norm Distribution (Conventional Norming)"
subtitle <- bquote(paste("Model: ", .(m$ideal.model), ", R"^2, " = ", .(round(
m$subsets$adjr2[[m$ideal.model]], digits = 4
))))
}
p <- ggplot() +
geom_point(
data = manifest_df,
aes(x = .data$raw, y = .data$percentile),
colour = "black",
alpha = 0.6,
size = 1.4
) +
geom_line(
data = curve_df,
aes(x = .data$raw, y = .data$percentile),
colour = "#1f77b4",
linewidth = 0.9
) +
labs(
title = title,
subtitle = subtitle,
x = paste0("Raw Score (", raw_var, ")"),
y = "Percentile"
) +
scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, by = 10)) +
theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
plot.subtitle = element_text(hjust = 0.5, size = 12),
axis.title = element_text(size = 12, face = "bold"),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
print(p)
invisible(p)
}
#' Plot norm curves against actual percentiles
#'
#' The function plots the norm curves based on the regression model against
#' the actual percentiles from the raw data. As in 'plotNormCurves',
#' please check for inconsistent curves, especially intersections.
#' Violations of this assumption are a strong indication for problems
#' in modeling the relationship between raw and norm scores.
#' In general, extrapolation (point 1 and 2) can carefully be done to a
#' certain degree outside the original sample, but it should in general
#' be handled with caution.
#' The original percentiles are displayed as distinct points in the according
#' color, the model based projection of percentiles are drawn as lines.
#' Please note, that the estimation of the percentiles of the raw data is done with
#' the quantile function with the default settings.
#' In case, you get 'jagged' or disorganized percentile curve, try to reduce the 'k'
#' and/or 't' parameter in modeling.
#'
#' @param model The Taylor polynomial regression model object from the cNORM
#' @param minRaw Lower bound of the raw score (default = 0)
#' @param maxRaw Upper bound of the raw score
#' @param minAge Variable to restrict the lower bound of the plot to a specific age
#' @param maxAge Variable to restrict the upper bound of the plot to a specific age
#' @param raw The name of the raw variable
#' @param group The name of the grouping variable; the distinct groups are automatically
#' determined
#' @param percentiles Vector with percentile scores, ranging from 0 to 1 (exclusive)
#' @param scale The norm scale, either 'T', 'IQ', 'z', 'percentile' or
#' self defined with a double vector with the mean and standard deviation,
#' f. e. c(10, 3) for Wechsler scale index points; if NULL, scale information from the
#' data preparation is used (default)
#' @param title custom title for plot
#' @param subtitle custom title for plot
#' @param points Logical indicating whether to plot the data points. Default is TRUE.
#' @seealso plotNormCurves, plotPercentileSeries
#' @examples
#' \dontrun{
#' # Load example data set, compute model and plot results
#' result <- cnorm(raw = elfe$raw, group = elfe$group)
#' plotPercentiles(result)
#' }
#' @export
#' @family plot
plotPercentiles <- function(model,
minRaw = NULL,
maxRaw = NULL,
minAge = NULL,
maxAge = NULL,
raw = NULL,
group = NULL,
percentiles = c(0.025, 0.1, 0.25, 0.5, 0.75, 0.9, 0.975),
scale = NULL,
title = NULL,
subtitle = NULL,
points = FALSE) {
if (isParametric(model)) {
stop(
"This function is not applicable for parametric models (Beta Binomial or Sinh-Arcsinh). ",
"Please use 'plot(model, age, raw)' instead."
)
}
if (isTaylor(model)) {
data <- model$data
m <- model$model
} else {
stop("Please provide a cnorm object.")
}
if (!isTRUE(m$useAge)) {
return(
plotCumulative(
model,
minRaw = minRaw,
maxRaw = maxRaw,
title = title,
subtitle = subtitle
)
)
}
if (is.null(group)) {
group <- attr(data, "group")
}
age <- NULL
if (is.null(data[[group]])) {
age <- data[, attributes(data)$age]
data$group <- getGroups(data[, attributes(data)$age])
data$age <- data[, attributes(data)$age]
group <- "group"
}
if (is.null(minAge)) {
minAge <- m$minA1
}
if (is.null(maxAge)) {
maxAge <- m$maxA1
}
if (is.null(minRaw)) {
minRaw <- m$minRaw
}
if (is.null(maxRaw)) {
maxRaw <- m$maxRaw
}
if (is.null(raw)) {
raw <- m$raw
}
if (!(raw %in% colnames(data))) {
stop(paste(
c(
"ERROR: Raw score variable '",
raw,
"' does not exist in data object."
),
collapse = ""
))
}
if (!(group %in% colnames(data))) {
stop(paste(
c(
"ERROR: Grouping variable '",
group,
"' does not exist in data object."
),
collapse = ""
))
}
if (typeof(group) == "logical" && !group) {
stop("The plotPercentiles-function does not work without a grouping variable.")
}
# compute norm scores from percentile vector
if (is.null(scale)) {
# fetch scale information from model
T <- qnorm(percentiles, m$scaleM, m$scaleSD)
} else if ((typeof(scale) == "double" && length(scale) == 2)) {
T <- qnorm(percentiles, scale[1], scale[2])
} else if (scale == "IQ") {
T <- qnorm(percentiles, 100, 15)
} else if (scale == "z") {
T <- qnorm(percentiles)
} else if (scale == "T") {
T <- qnorm(percentiles, 50, 10)
} else {
# no transformation
T <- percentiles
}
# generate variable names
NAMES <- paste("PR", percentiles * 100, sep = "")
NAMESP <- paste("PredPR", percentiles * 100, sep = "")
# build function for xyplot and aggregate actual percentiles per group
xyFunction <- paste(
paste(NAMES, collapse = " + "),
paste(NAMESP, collapse = " + "),
sep = " + ",
collapse = " + "
)
xyFunction <- paste(xyFunction, group, sep = " ~ ")
w <- attributes(data)$weights
data[, group] <- round(data[, group], digits = 3)
AGEP <- unique(data[, group])
# get actual percentiles
if (!is.null(attr(data, "descend")) && attr(data, "descend")) {
percentile.actual <- as.data.frame(do.call("rbind", lapply(split(data, data[, group]), function(df) {
weighted.quantile(df[, raw],
probs = 1 - percentiles,
weights = df$w)
})))
} else{
percentile.actual <- as.data.frame(do.call("rbind", lapply(split(data, data[, group]), function(df) {
weighted.quantile(df[, raw], probs = percentiles, weights = df$w)
})))
}
percentile.actual$group <- as.numeric(rownames(percentile.actual))
colnames(percentile.actual) <- c(NAMES, c(group))
rownames(percentile.actual) <- AGEP
# build finer grained grouping variable for prediction and fit predicted percentiles
share <- seq(from = minAge,
to = maxAge,
length.out = 100)
AGEP <- c(AGEP, share)
percentile.fitted <- data.frame(matrix(NA, nrow = length(AGEP), ncol = length(T)))
norm_rep <- rep(T, times = length(AGEP)) # T1,T2,...,Tk, T1,T2,...,Tk, ...
age_rep <- rep(AGEP, each = length(T)) # A1,A1,...,A1, A2,A2,...,A2, ...
preds <- predictRaw(norm_rep,
age_rep,
m$coefficients,
minRaw = minRaw,
maxRaw = maxRaw)
percentile.fitted <- as.data.frame(matrix(
preds,
nrow = length(AGEP),
ncol = length(T),
byrow = TRUE
))
percentile.fitted$group <- AGEP
percentile.fitted <- percentile.fitted[!duplicated(percentile.fitted$group), ]
colnames(percentile.fitted) <- c(NAMESP, group)
rownames(percentile.fitted) <- percentile.fitted$group
# Merge actual and predicted scores and plot them show lines
# for predicted scores and dots for actual scores
percentile <- merge(percentile.actual,
percentile.fitted,
by = group,
all = TRUE)
END <- .8
COL1 <- rainbow(length(percentiles), end = END)
COL2 <- c(rainbow(length(percentiles), end = END), rainbow(length(percentiles), end = END))
if (is.null(title)) {
title <- "Observed and Predicted Percentile Curves"
subtitle <- bquote(paste("Model: ", .(m$ideal.model), ", R"^2, "=", .(round(
m$subsets$adjr2[[m$ideal.model]], digits = 4
))))
}
# Prepare data for ggplot
plot_data <- data.frame(
group = rep(percentile$group, 2 * length(percentiles)),
value = c(as.matrix(percentile[, NAMES]), as.matrix(percentile[, NAMESP])),
type = rep(
c("Observed", "Predicted"),
each = nrow(percentile) * length(percentiles)
),
percentile = factor(rep(rep(
NAMES, each = nrow(percentile)
), 2), levels = NAMES)
)
plot_data_predicted <- plot_data[plot_data$type == "Predicted", ]
plot_data_observed <- plot_data[plot_data$type == "Observed", ]
# Create the ggplot - ALIGNED ORDER: Points first, then lines
p <- ggplot()
# Add raw scores FIRST if points is TRUE (matches plot.cnormShash)
if (points) {
if (is.null(age)) {
p <- p + geom_point(
data = data,
aes(x = .data[[group]], y = .data[[raw]]),
color = "black",
alpha = 0.2,
size = 0.6
) # Matched size
} else{
p <- p + geom_point(
data = data,
aes(x = .data$age, y = .data[[raw]]),
color = "black",
alpha = 0.2,
size = 0.6
) # Matched size
}
}
# Then add percentile lines and observed points
p <- p +
geom_line(
data = plot_data_predicted,
aes(
x = .data$group,
y = .data$value,
color = .data$percentile
),
linewidth = 0.6
) + # Matched linewidth
geom_point(
data = plot_data_observed,
aes(
x = .data$group,
y = .data$value,
color = .data$percentile
),
na.rm = TRUE,
size = 2,
shape = 18
) +
labs(
title = title,
subtitle = subtitle,
x = paste0("Explanatory Variable (", group, ")"),
y = paste0("Raw Score (", raw, ")"),
color = "Percentile"
) + # Added legend title
scale_color_manual(
values = setNames(COL1, NAMES),
labels = paste0(percentiles * 100, "%") # Matched label format
) +
guides(color = guide_legend(override.aes = list(
linetype = rep("solid", length(NAMES)),
shape = rep(18, length(NAMES))
))) # Matched legend override
# Apply consistent theme
p <- p + theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
plot.subtitle = element_text(hjust = 0.5, size = 12),
axis.title = element_text(size = 12, face = "bold"),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 10),
legend.position = "right",
legend.title = element_text(size = 12, face = "bold"),
legend.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
print(p)
invisible(p)
}
#' Plot the density function per group by raw score
#'
#' This function plots density curves based on the regression model against the raw scores.
#' It supports both traditional continuous norming models and beta-binomial models.
#' The function allows for customization of the plot range and groups to be displayed.
#'
#' @param model The model from the bestModel function, a cnorm object, a cnormBetaBinomial, a cnormBetaBinomial2 or
#' cnormShash object.
#' @param minRaw Lower bound of the raw score. If NULL, it's automatically determined based on the model type.
#' @param maxRaw Upper bound of the raw score. If NULL, it's automatically determined based on the model type.
#' @param minNorm Lower bound of the norm score. If NULL, it's automatically determined based on the model type.
#' @param maxNorm Upper bound of the norm score. If NULL, it's automatically determined based on the model type.
#' @param group Numeric vector specifying the age groups to plot. If NULL, groups are automatically selected.
#'
#' @return A ggplot object representing the density functions.
#'
#' @details
#' The function generates density curves for specified age groups, allowing for easy comparison of score distributions
#' across different ages.
#'
#' For beta-binomial models, the density is based on the probability mass function, while for
#' traditional models, it uses a normal distribution based on the norm scores.
#'
#' @note
#' Please check for inconsistent curves, especially those showing implausible shapes
#' such as violations of biuniqueness in the cnorm models.
#'
#' @seealso \code{\link{plotNormCurves}}, \code{\link{plotPercentiles}}
#'
#' @examples
#' \dontrun{
#' # For traditional continuous norming model
#' result <- cnorm(raw = elfe$raw, group = elfe$group)
#' plotDensity(result, group = c(2, 4, 6))
#'
#' # For beta-binomial model
#' bb_model <- cnorm.betabinomial(age = ppvt$age, score = ppvt$raw, n = 228)
#' plotDensity(bb_model)
#' }
#'
#' @import ggplot2
#' @export
#' @family plot
plotDensity <- function(model,
minRaw = NULL,
maxRaw = NULL,
minNorm = NULL,
maxNorm = NULL,
group = NULL) {
if (isTaylor(model)) {
model <- model$model
}
is_beta_binomial <- isBeta(model)
is_shash <- isSHASH(model)
if (is.null(minNorm)) {
minNorm <- if (is_beta_binomial || is_shash)
-3
else
model$minL1
}
if (is.null(maxNorm)) {
maxNorm <- if (is_beta_binomial || is_shash)
3
else
model$maxL1
}
if (is.null(minRaw)) {
minRaw <- if (is_beta_binomial)
0
else if (is_shash)
attr(model$result, "min")
else
model$minRaw
}
if (is.null(maxRaw)) {
maxRaw <- if (is_beta_binomial || is_shash)
attr(model$result, "max")
else
model$maxRaw
}
if (is.null(group)) {
if (is_beta_binomial || is_shash) {
age_min <- attr(model$result, "ageMin")
age_max <- attr(model$result, "ageMax")
group <- round(seq(
from = age_min,
to = age_max,
length.out = 4
), digits = 3)
} else if (model$useAge) {
group <- round(seq(
from = model$minA1,
to = model$maxA1,
length.out = 4
),
digits = 3)
} else {
group <- c(1)
}
}
step <- (maxNorm - minNorm) / 100
# Calculate step size uniquely required for continuous density approximation for shash
step_shash <- NULL
if (is_shash) {
step_shash <- (maxRaw - minRaw) / 100
if (step_shash <= 0) step_shash <- 1
}
matrix_list <- lapply(group, function(g) {
if (is_beta_binomial) {
norm <- normTable.betabinomial(model, ages = g, n = attr(model$result, "max"))[[1]]
norm$group <- rep(g, length.out = nrow(norm))
colnames(norm)[colnames(norm) == "x"] <- "raw"
colnames(norm)[colnames(norm) == "norm"] <- "norm1"
colnames(norm)[colnames(norm) == "z"] <- "norm"
} else if (is_shash) {
norm <- normTable.shash(model, ages = g, start = minRaw, end = maxRaw, step = step_shash)[[1]]
norm$group <- rep(g, length.out = nrow(norm))
colnames(norm)[colnames(norm) == "x"] <- "raw"
colnames(norm)[colnames(norm) == "norm"] <- "norm1"
colnames(norm)[colnames(norm) == "z"] <- "norm"
} else {
norm <- normTable(
g,
model = model,
minNorm = minNorm,
maxNorm = maxNorm,
minRaw = minRaw,
maxRaw = maxRaw,
step = step,
pretty = FALSE
)
norm$group <- rep(g, length.out = nrow(norm))
}
return(norm)
})
matrix <- do.call(rbind, matrix_list)
matrix <- matrix[matrix$norm > minNorm & matrix$norm < maxNorm, ]
matrix <- matrix[matrix$raw > minRaw & matrix$raw < maxRaw, ]
if (is_beta_binomial) {
matrix$density <- matrix$Px
} else if (is_shash) {
# Re-scale back to pure density utilizing defined step_shash
matrix$density <- matrix$Px / step_shash
} else {
matrix$density <- dnorm(matrix$norm,
mean = model$scaleM,
sd = model$scaleSD)
}
# Create ggplot
title <- ""
if (is_beta_binomial) {
title <- "Density Functions (Beta-Binomial)"
} else if (is_shash) {
title <- "Density Functions (SHASH)"
} else {
title <- "Density Functions (Taylor Polynomial)"
}
matrix <- matrix[complete.cases(matrix), ]
p <- ggplot(matrix, aes(
x = .data$raw,
y = .data$density,
color = factor(group)
)) +
geom_line(linewidth = 1, na.rm = TRUE) +
scale_color_viridis_d(name = "Group",
labels = paste("Group", group),
option = "plasma") +
labs(title = title, x = "Raw Score", y = "Density") +
theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
axis.title = element_text(size = 12, face = "bold"),
axis.text = element_text(size = 10),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
legend.position = "right",
legend.title = element_text(size = 12, face = "bold"),
legend.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
return(p)
}
#' Generates a series of plots with percentile curves for different models
#'
#' This function makes use of 'plotPercentiles' to generate a series of plots
#' for models with an increasing number of terms. It draws on the information
#' provided by the model object to determine the bounds of the modeling (age
#' and standard score range). It can be used as an additional model check to
#' determine the best fitting model. Please have a look at the
#' 'plotPercentiles' function for further information.
#'
#' Each model of the series is refitted on the complete norm sample (applying
#' case weights, if the norm data were post stratified). Models are identified
#' by their actual number of terms, which - after consistency screening in
#' \code{bestModel} - is not necessarily identical to the row number of the
#' model selection table. The subtitle of each plot reports the number of
#' terms, the adjusted R2 and, if available, the result of the consistency
#' check.
#'
#' @param model The Taylor polynomial regression model object or a cnorm object
#' @param start Number of terms to start with (default 1)
#' @param end Number of terms to end with; defaults to the largest available
#' model
#' @param group The name of the grouping variable; the distinct groups are
#' automatically determined
#' @param percentiles Vector with percentile scores, ranging from 0 to 1
#' (exclusive)
#' @param filename Prefix of the filename. If specified, the plots are saved as
#' png files in the directory of the workspace, instead of only displaying
#' them. The number of terms is appended to the prefix.
#' @seealso plotPercentiles
#' @return A named list of plots (names indicate the number of terms),
#' returned invisibly
#' @export
#'
#' @examples
#' \dontrun{
#' # Load example data set, compute model and plot results
#' result <- cnorm(raw = elfe$raw, group = elfe$group)
#' plotPercentileSeries(result, start = 4, end = 6)
#' }
#'
#' @family plot
plotPercentileSeries <- function(model,
start = 1,
end = NULL,
group = NULL,
percentiles = c(0.025, 0.1, 0.25, 0.5, 0.75, 0.9, 0.975),
filename = NULL) {
if (isParametric(model)) {
stop("This function is not applicable for parametric models (Beta Binomial ",
"or Sinh-Arcsinh). Please use the plotDensity function instead.")
}
if (isTaylor(model)) {
d <- model$data
model <- model$model
} else {
stop("Please provide a cnorm object.")
}
if (!isTRUE(attr(d, "useAge"))) {
stop("Age or group variable explicitly set to FALSE in dataset. ",
"No plotting available.")
}
subsets <- model$subsets
outmat <- subsets$outmat
nTerms <- rowSums(outmat == "*") # actual model sizes (row index is
# NOT reliable after screening)
maxTerms <- max(nTerms)
# sanitize requested range
if (is.null(end) || end > maxTerms) end <- maxTerms
if (start < 1) start <- 1
if (start > end) start <- end
# select the rows whose term count falls into the requested range;
# after screening there is one model per size, otherwise take the first
rows <- which(nTerms >= start & nTerms <= end)
rows <- rows[!duplicated(nTerms[rows])]
if (length(rows) == 0L) {
stop("No models with ", start, " to ", end, " terms available.")
}
# case weights (post stratification), analogous to bestModel
w <- if (!is.null(attr(d, "weights")) && !is.null(d$weights))
d$weights else NULL
# static model information, assembled once
minR <- min(d[[model$raw]])
maxR <- max(d[[model$raw]])
fields <- list(
ideal.model = model$ideal.model,
cutoff = model$cutoff,
useAge = model$useAge,
minA1 = model$minA1,
maxA1 = model$maxA1,
minL1 = model$minL1,
maxL1 = model$maxL1,
minRaw = minR,
maxRaw = maxR,
raw = model$raw,
scaleSD = attr(d, "scaleSD"),
scaleM = attr(d, "scaleM"),
descend = attr(d, "descend"),
group = attr(d, "group"),
age = attr(d, "age"),
k = attr(d, "k"),
A = attr(d, "A")
)
termNames <- colnames(outmat)
l <- vector("list", length(rows))
names(l) <- as.character(nTerms[rows])
for (idx in seq_along(rows)) {
row <- rows[idx]
size <- nTerms[row]
message("Plotting model with ", size, " terms ...")
# refit the candidate model on the complete sample
selected <- termNames[outmat[row, ] == "*"]
f <- stats::reformulate(selected, response = model$raw)
bestformula <- if (is.null(w)) stats::lm(f, data = d)
else stats::lm(f, data = d, weights = w)
# attach the static model information
bestformula[names(fields)] <- fields
result <- list(data = d, model = bestformula)
class(result) <- "cnormTemp"
# subtitle: actual size, fit and consistency information
r2 <- round(subsets$adjr2[row], digits = 4)
consInfo <- if (!is.null(subsets$consistent) && !is.na(subsets$consistent[row])) {
if (subsets$consistent[row]) ", consistent" else ", inconsistent"
} else {
""
}
l[[idx]] <- plotPercentiles(
result,
minAge = model$minA1,
maxAge = model$maxA1,
minRaw = minR,
maxRaw = maxR,
percentiles = percentiles,
scale = NULL,
group = group,
title = "Observed and Predicted Percentiles",
subtitle = bquote(paste("Model with ", .(size), " terms, ",
R^2, " = ", .(r2), .(consInfo)))
)
if (!is.null(filename)) {
ggsave(
filename = paste0(filename, size, ".png"),
plot = l[[idx]],
device = "png",
width = 10,
height = 7,
dpi = 300
)
}
}
invisible(l)
}
#' #' Evaluate information criteria for regression model
#'
#' This function plots various information criteria and model fit statistics
#' against the number of terms or the adjusted R-squared, depending on the type
#' of plot selected. It helps in model selection by visualizing different
#' aspects of model performance. Models which did not pass the consistency
#' check are depicted with an empty circle; the automatically selected model is
#' highlighted in red. If BIC-weighted model averaging was applied
#' (\code{averaging = TRUE}), a caption indicates that the final coefficients
#' are a weighted combination of the consistent candidate models shown.
#'
#' @param model The regression model from the bestModel function or a cnorm object.
#' @param type Integer specifying the type of plot to generate:
#' \itemize{
#' \item 0: Adjusted R2 by number of terms (default)
#' \item 1: Log-transformed Mallows's Cp by adjusted R2
#' \item 2: Bayesian Information Criterion (BIC) by adjusted R2
#' \item 3: Root Mean Square Error (RMSE) by number of terms
#' \item 4: Residual Sum of Squares (RSS) by number of terms
#' \item 5: F-test statistic for consecutive models by number of terms
#' \item 6: p-value for model tests by number of terms
#' }
#'
#' @return A ggplot object representing the selected information criterion plot.
#'
#' @details
#' The function generates different plots to help in model selection:
#'
#' - For types 1 and 2 (Mallows's Cp and BIC), look for the "elbow" in the curve
#' where the information criterion begins to drop. This often indicates a good
#' balance between model fit and complexity.
#' - For type 0 (Adjusted R2), higher values indicate better fit, but be cautious
#' of overfitting with values approaching 1.
#' - For types 3 and 4 (RMSE and RSS), lower values indicate better fit.
#' - For type 5 (F-test), higher values suggest significant improvement with
#' added terms.
#' - For type 6 (p-values), values below the significance level (typically 0.05)
#' suggest significant improvement with added terms.
#'
#' The F-tests and p-values compare each model with the preceding (smaller) one,
#' with degrees of freedom based on the actual difference in the number of
#' parameters. After consistency screening, consecutive models may differ by
#' more than one term.
#'
#' @note
#' It's important to balance statistical measures with practical considerations
#' and to visually inspect the model fit using functions like
#' \code{plotPercentiles}.
#'
#' @seealso \code{\link{bestModel}}, \code{\link{plotPercentiles}}, \code{\link{printSubset}}
#'
#' @examples
#' \dontrun{
#' # Compute model with example data and plot information function
#' cnorm.model <- cnorm(raw = elfe$raw, group = elfe$group)
#' plotSubset(cnorm.model)
#'
#' # Plot BIC against adjusted R-squared
#' plotSubset(cnorm.model, type = 2)
#'
#' # Plot RMSE against number of terms
#' plotSubset(cnorm.model, type = 3)
#' }
#'
#' @import ggplot2
#' @export
#' @family plot
plotSubset <- function(model, type = 0) {
if (isParametric(model)) {
stop("This function is not applicable for parametric models ",
"(Beta Binomial or Sinh-Arcsinh).")
}
if (isTaylor(model)) {
model <- model$model
}
if (is.null(model$subsets)) {
stop("The model object does not contain model selection information ('subsets').")
}
if (!(type %in% 0:6)) {
warning("Unknown plot type; using type = 0 (adjusted R2).")
type <- 0
}
subsets <- model$subsets
nModels <- length(subsets$rss)
n <- length(model$fitted.values)
# actual model complexity per row (row index != number of terms
# after consistency screening!)
nTerms <- rowSums(subsets$outmat == "*")
nParams <- nTerms + 1L
# F-tests between consecutive models, df from actual parameter counts
Fvals <- rep(NA_real_, nModels)
pvals <- rep(NA_real_, nModels)
if (nModels > 1L) {
df1 <- diff(nParams)
df1[df1 < 1L] <- NA # guard non-nested comparisons
df2 <- n - nParams[-1L]
Fs <- (-diff(subsets$rss) / df1) / (subsets$rss[-1L] / df2)
Fvals[-1L] <- Fs
pvals[-1L] <- stats::pf(Fs, df1, df2, lower.tail = FALSE)
}
# consistency flags: NA (unscreened, e.g. custom predictors) shown as filled
consistent <- subsets$consistent
if (is.null(consistent)) consistent <- rep(TRUE, nModels)
consistent[is.na(consistent)] <- TRUE
cutoff <- if (!is.null(model$cutoff)) model$cutoff else .99
sel <- if (!is.null(model$ideal.model)) model$ideal.model else NA_integer_
dat <- data.frame(
adjr2 = subsets$adjr2,
bic = subsets$bic,
cp = subsets$cp,
RSS = subsets$rss,
RMSE = sqrt(subsets$rss / n),
Fstat = Fvals,
pval = pvals,
nr = nTerms,
consistency = factor(ifelse(consistent, "consistent", "inconsistent"),
levels = c("consistent", "inconsistent"))
)
# ---- plot configuration per type -----------------------------------------
xlab_terms <- "Number of terms"
xlab_r2 <- expression(paste("Adjusted ", R^2))
cfg <- switch(type + 1L,
list(x = "nr", y = "adjr2", # 0
title = expression(paste("Information Function: Adjusted ", R^2)),
xlab = xlab_terms,
ylab = expression(paste("Adjusted ", R^2))),
list(x = "adjr2", y = "cp", # 1
title = "Information Function: Mallows's Cp",
xlab = xlab_r2,
ylab = "Mallows's Cp"),
list(x = "adjr2", y = "bic", # 2
title = "Information Function: BIC",
xlab = xlab_r2,
ylab = "Bayesian Information Criterion (BIC)"),
list(x = "nr", y = "RMSE", # 3
title = "Information Function: RMSE",
xlab = xlab_terms,
ylab = "Root Mean Square Error (Raw Score)"),
list(x = "nr", y = "RSS", # 4
title = "Information Function: RSS",
xlab = xlab_terms,
ylab = "Residual Sum of Squares (RSS)"),
list(x = "nr", y = "Fstat", # 5
title = "Information Function: F-test Statistics",
xlab = xlab_terms,
ylab = "F-test Statistics for Consecutive Models"),
list(x = "nr", y = "pval", # 6
title = "Information Function: p-values",
xlab = xlab_terms,
ylab = expression(paste("p-values for Tests on ", R^2,
" adj. of Consecutive Models")))
)
theme_custom <- theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 16, hjust = 0.5),
axis.title = element_text(face = "bold", size = 12),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 10),
legend.title = element_blank(),
legend.text = element_text(size = 10),
# show the shape legend only if inconsistent models are present
legend.position = if (any(dat$consistency == "inconsistent"))
"bottom" else "none",
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
# ---- base plot -------------------------------------------------------------
plt <- ggplot(dat, aes(x = .data[[cfg$x]], y = .data[[cfg$y]])) +
theme_custom +
geom_line(color = "#1f77b4", linewidth = .75, na.rm = TRUE) +
geom_point(aes(shape = .data$consistency),
color = "#1f77b4", size = 2.5, na.rm = TRUE) +
# named values: robust even when only one level is present
scale_shape_manual(values = c(consistent = 16, inconsistent = 1),
drop = FALSE) +
labs(title = cfg$title, x = cfg$xlab, y = cfg$ylab, shape = NULL)
# highlight the selected model
if (!is.na(sel) && sel >= 1L && sel <= nModels) {
plt <- plt +
geom_point(data = dat[sel, , drop = FALSE],
aes(shape = .data$consistency),
color = "#3322AA", size = 2.5, stroke = 1.1, na.rm = TRUE)
}
# ---- type-specific decorations ----------------------------------------------
if (type == 0) {
plt <- plt +
geom_hline(yintercept = cutoff, linetype = "dashed",
linewidth = .8, color = "#d62728")
} else if (type == 1) {
# Mallows's Cp can legitimately be <= 0; log scale only when valid
if (all(dat$cp > 0, na.rm = TRUE)) {
plt <- plt + scale_y_log10() +
labs(y = "log-transformed Mallows's Cp")
} else {
message("Mallows's Cp contains non-positive values; ",
"using a linear scale instead of log10.")
}
} else if (type == 6) {
plt <- plt +
geom_hline(yintercept = 0.05, linetype = "dashed",
linewidth = 1, color = "#d62728") +
# clip the view without silently removing observations (unlike ylim)
coord_cartesian(ylim = c(-0.005, 0.11))
}
# indicate model averaging
if (isTRUE(model$averaged)) {
plt <- plt + labs(caption = paste0(
"Final coefficients: BIC-weighted average over ",
length(model$averagingWeights), " consistent candidate models."))
}
return(plt)
}
#'
#' @title Plot first order derivative of regression model
#'
#' @description
#' This function plots the scores obtained via the first order derivative of the regression model
#' in dependence of the norm score.
#'
#' @param model The model from the bestModel function, a cnorm object.
#' @param minAge Minimum age to start checking. If NULL, it's automatically determined from the model.
#' @param maxAge Maximum age for checking. If NULL, it's automatically determined from the model.
#' @param minNorm Lower end of the norm score range. If NULL, it's automatically determined from the model.
#' @param maxNorm Upper end of the norm score range. If NULL, it's automatically determined from the model.
#' @param stepAge Stepping parameter for the age check, usually 1 or 0.1; lower values indicate higher precision.
#' @param stepNorm Stepping parameter for norm scores.
#' @param order Degree of the derivative (default = 1).
#'
#' @details
#' The results indicate the progression of the norm scores within each age group. The regression-based
#' modeling approach relies on the assumption of a linear progression of the norm scores. Negative scores
#' in the first order derivative indicate a violation of this assumption. Scores near zero are typical
#' for bottom and ceiling effects in the raw data.
#'
#' The regression models usually converge within the range of the original values. In case of vertical
#' and horizontal extrapolation, with increasing distance to the original data, the risk of assumption
#' violation increases as well.
#'
#' @note
#' This function is currently incompatible with reversed raw score scales ('descent' option).
#'
#' @return A ggplot object representing the derivative of the regression function.
#'
#' @seealso \code{\link{checkConsistency}}, \code{\link{bestModel}}, \code{\link{derive}}
#'
#' @examples
#' \dontrun{
#' # For traditional continuous norming model
#' result <- cnorm(raw = elfe$raw, group = elfe$group)
#' plotDerivative(result, minAge=2, maxAge=5, stepAge=.2, minNorm=25, maxNorm=75, stepNorm=1)
#' }
#'
#' @import ggplot2
#' @export
#' @family plot
plotDerivative <- function(model,
minAge = NULL,
maxAge = NULL,
minNorm = NULL,
maxNorm = NULL,
stepAge = NULL,
stepNorm = NULL,
order = 1) {
if (isTaylor(model)) {
model <- model$model
} else if (isParametric(model)) {
stop(
"This function is not applicable for parametric models (Beta Binomial or Sinh-Arcsinh). Please use the plotDensity function instead."
)
}
if (!model$useAge) {
stop("Age or group variable explicitly set to FALSE in dataset. No plotting available.")
}
if (is.null(minAge)) {
minAge <- model$minA1
}
if (is.null(maxAge)) {
maxAge <- model$maxA1
}
if (is.null(minNorm)) {
minNorm <- model$minL1
}
if (is.null(maxNorm)) {
maxNorm <- model$maxL1
}
if (is.null(stepAge)) {
stepAge <- (maxAge - minAge) / 100
}
if (is.null(stepNorm)) {
stepNorm <- (maxNorm - minNorm) / 100
}
if (order <= 0)
stop("Order of derivative must be a positive integer.")
rowS <- seq(minNorm, maxNorm, by = stepNorm)
colS <- seq(minAge, maxAge, by = stepAge)
coeff <- derive(model, order)
if (length(coeff) == 0) {
stop("Derivative of order ", order, " not available for this model.")
}
cat(
paste0(
rangeCheck(model, minAge, maxAge, minNorm, maxNorm),
" Coefficients from the ",
order,
" order derivative function:\n\n"
)
)
print(coeff)
dev2 <- expand.grid(X = rowS, Y = colS)
dev2$Z <- mapply(function(norm, age)
predictRaw(norm, age, coeff), dev2$X, dev2$Y)
ordinal <- if (order <= 3)
c("st", "nd", "rd")[order]
else
"th"
desc <- paste0(order, ordinal, " Order Derivative")
custom_palette <- c(
"#FF0000",
"#FF4000",
"#FF8000",
"#FFBF00",
"#FFFF00",
"#80FF00",
"#00FF00",
"#00FF80",
"#00FFFF",
"#0080FF",
"#0000FF",
"#4B0082",
"#8B00FF"
)
theme_custom <- theme_minimal() +
theme(
plot.title = element_text(
face = "bold",
size = 14,
hjust = 0.5
),
axis.title = element_text(face = "bold", size = 12),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 8),
legend.position = "right",
legend.text = element_text(size = 8),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
p <- ggplot(dev2, aes(x = .data$Y, y = .data$X, z = .data$Z)) +
geom_tile(aes(fill = .data$Z)) +
geom_contour(color = "white", alpha = 0.5) +
scale_fill_gradientn(colors = custom_palette) +
labs(
title = "Slope of the Regression Function",
x = "Explanatory Variable (Age)",
y = paste("Norm Score - ", desc),
fill = "Derivative"
) +
theme_custom +
theme(legend.position = "right")
if (min(dev2$Z) < 0 && max(dev2$Z) > 0)
p <- p + geom_contour(
aes(z = .data$Z),
color = "black",
linewidth = 0.5,
breaks = 0,
linetype = "dashed"
)
return(p)
}
#' General convenience plotting function
#'
#' @param x a cnorm object
#' @param y the type of plot as a string, can be one of
#' 'raw' (1), 'norm' (2), 'curves' (3), 'percentiles' (4), 'series' (5), 'subset' (6),
#' or 'derivative' (7), either as a string or the according index
#' @param ... additional parameters for the specific plotting function
#'
#' @export
plotCnorm <- function(x, y, ...) {
if (!isTaylor(x)) {
message("Please provide a cnorm object as x.")
return(invisible(NULL))
}
if (!is.character(y) && !is.numeric(y)) {
message("y must be a plot-type string or integer index.")
return(invisible(NULL))
}
if (y == "raw" || y == 1)
plotRaw(x, ...)
else if (y == "norm" || y == 2)
plotNorm(x, ...)
else if (y == "curves" || y == 3)
plotNormCurves(x, ...)
else if (y == "percentiles" || y == 4)
plotPercentiles(x, ...)
else if (y == "density" || y == 5)
plotDensity(x, ...)
else if (y == "series" ||
y == 6)
plotPercentileSeries(x, ...)
else if (y == "subset" || y == 7)
plotSubset(x, ...)
else if (y == "derivative" || y == 8)
plotDerivative(x, ...)
else
stop("Unkown plot type")
}
#' Compare Two Norm Models Visually
#'
#' This function creates a visualization comparing two norm models by displaying
#' their percentile curves. The first model is shown with solid lines, the second
#' with dashed lines. If age and score vectors are provided, manifest percentiles
#' are displayed as dots. The function works with regular cnorm models, beta-binomial
#' models, and shash models, allowing comparison between different model types.
#'
#' For beta-binomial models, the exact quantiles of the discrete beta-binomial
#' distribution are displayed by default as step functions (\code{discrete = TRUE}).
#' Setting \code{discrete = FALSE} draws smooth lines based on the quantiles of
#' the underlying beta (mixing) distribution instead. Note that this continuous
#' approximation omits the binomial stage of the variance and therefore displays
#' less spread than the fitted model actually implies, particularly in the outer
#' percentiles. The parameter has no effect on Taylor polynomial or shash models.
#'
#' @param model1 First model object (distribution free, beta-binomial, or shash)
#' @param model2 Second model object (distribution free, beta-binomial, or shash)
#' @param age Optional vector with manifest age or group values
#' @param score Optional vector with manifest raw score values
#' @param weights Optional vector with manifest weights
#' @param percentiles Vector with percentile scores, ranging from 0 to 1 (exclusive)
#' @param title Custom title for plot (optional)
#' @param subtitle Custom subtitle for plot (optional)
#' @param discrete Logical indicating whether beta-binomial models are displayed
#' with their exact discrete quantiles as step functions (TRUE, default) or
#' with a smooth continuous approximation via the underlying beta
#' distribution (FALSE). Ignored for other model types.
#'
#' @return A ggplot object showing the comparison of both models
#'
#' @examples
#' \dontrun{
#' # Compare different types of models
#' model1 <- cnorm(group = elfe$group, raw = elfe$raw)
#' model2 <- cnorm.betabinomial(elfe$group, elfe$raw)
#' model3 <- cnorm.shash(elfe$group, elfe$raw)
#'
#' # Compare traditional cnorm with shash
#' compare(model1, model3, age = elfe$group, score = elfe$raw)
#'
#' # Compare beta-binomial with shash
#' compare(model2, model3, age = elfe$group, score = elfe$raw)
#' }
#'
#' @export
#' @family plot
compare <- function(model1,
model2,
percentiles = c(0.025, 0.1, 0.25, 0.5, 0.75, 0.9, 0.975),
age = NULL,
score = NULL,
weights = NULL,
title = NULL,
subtitle = NULL,
discrete = TRUE) {
# retrieve score from model if score is null and one of the
# models is a cnorm object
if (is.null(score) && isTaylor(model1)) {
score <- model1$data[[attributes(model1$data)$raw]]
age <- model1$data[[attributes(model1$data)$age]]
}
if (is.null(score) && isTaylor(model2)) {
score <- model2$data[[attributes(model2$data)$raw]]
age <- model2$data[[attributes(model2$data)$age]]
}
# Function to get predictions for beta-binomial models
get_bb_predictions <- function(model, pred_ages) {
if (inherits(model, "cnormBetaBinomial")) {
preds <- predictCoefficients(model, pred_ages)
} else {
preds <- predictCoefficients2(model, pred_ages)
}
n_max <- attr(model$result, "max")
pred_matrix <- matrix(NA_real_,
nrow = length(pred_ages),
ncol = length(percentiles))
if (discrete) {
# Exact quantiles of the discrete beta-binomial distribution;
# one pmf evaluation per age, all percentiles read from it
for (j in seq_along(pred_ages)) {
dist <- bb_distribution(preds$a[j], preds$b[j], n_max)
if (!anyNA(dist$cum)) {
pred_matrix[j, ] <- vapply(percentiles, function(p) {
as.numeric(dist$x[which.max(dist$cum >= p)])
}, numeric(1))
}
}
} else {
# Continuous approximation via the underlying beta (mixing)
# distribution; omits the binomial-stage variance
for (i in seq_along(percentiles)) {
pred_matrix[, i] <- qbeta(percentiles[i],
shape1 = preds$a,
shape2 = preds$b) * n_max
}
}
pred_data <- data.frame(age = pred_ages, pred_matrix)
names(pred_data)[-1] <- paste0("P", percentiles * 100)
return(pred_data)
}
# Function to get predictions for shash models
get_shash_predictions <- function(model, pred_ages) {
preds <- predictCoefficients_shash(model, pred_ages)
pred_matrix <- matrix(NA,
nrow = length(pred_ages),
ncol = length(percentiles))
for (i in seq_along(percentiles)) {
pred_matrix[, i] <- qshash(
percentiles[i],
mu = preds$mu,
sigma = preds$sigma,
epsilon = preds$epsilon,
delta = preds$delta
)
}
pred_data <- data.frame(age = pred_ages, pred_matrix)
names(pred_data)[-1] <- paste0("P", percentiles * 100)
return(pred_data)
}
# Function to get predictions for cnorm models
get_cnorm_predictions <- function(model, pred_ages) {
m <- model$model
T <- qnorm(percentiles, m$scaleM, m$scaleSD)
pred_matrix <- matrix(NA,
nrow = length(pred_ages),
ncol = length(percentiles))
for (i in 1:length(pred_ages)) {
pred_matrix[i, ] <- predictRaw(T, pred_ages[i], m$coefficients)
}
pred_data <- data.frame(age = pred_ages, pred_matrix)
names(pred_data)[-1] <- paste0("P", percentiles * 100)
return(pred_data)
}
# Determine age range
get_age_range <- function(model) {
if (isParametric(model)) {
return(c(
attr(model$result, "ageMin"),
attr(model$result, "ageMax")
))
} else {
m <- model$model
return(c(m$minA1, m$maxA1))
}
}
# Get age ranges for both models
range1 <- get_age_range(model1)
range2 <- get_age_range(model2)
# Create common age sequence
pred_ages <- seq(min(range1[1], range2[1]), max(range1[2], range2[2]), length.out = 100)
# Get predictions for both models; remember which models are displayed
# as step functions (discrete beta-binomial quantiles)
step1 <- isBeta(model1) && discrete
step2 <- isBeta(model2) && discrete
plot_data1 <- if (isBeta(model1)) {
get_bb_predictions(model1, pred_ages)
} else if (isSHASH(model1)) {
get_shash_predictions(model1, pred_ages)
} else {
get_cnorm_predictions(model1, pred_ages)
}
plot_data2 <- if (isBeta(model2)) {
get_bb_predictions(model2, pred_ages)
} else if (isSHASH(model2)) {
get_shash_predictions(model2, pred_ages)
} else {
get_cnorm_predictions(model2, pred_ages)
}
# Prepare data for plotting (reshape to long format using base R)
plot_data_long <- data.frame(
age = numeric(),
value = numeric(),
percentile = character(),
model = character()
)
# Reshape data for model 1
for (i in 2:ncol(plot_data1)) {
plot_data_long <- rbind(
plot_data_long,
data.frame(
age = plot_data1$age,
value = plot_data1[[i]],
percentile = names(plot_data1)[i],
model = "Model 1"
)
)
}
# Reshape data for model 2
for (i in 2:ncol(plot_data2)) {
plot_data_long <- rbind(
plot_data_long,
data.frame(
age = plot_data2$age,
value = plot_data2[[i]],
percentile = names(plot_data2)[i],
model = "Model 2"
)
)
}
if (!is.null(score)) {
plot_data_long$value[plot_data_long$value < min(score)] <- min(score)
plot_data_long$value[plot_data_long$value > max(score)] <- max(score)
}
# Set factor levels for correct ordering
plot_data_long$percentile <- factor(plot_data_long$percentile, levels = paste0("P", percentiles * 100))
# Set default title if none provided
if (is.null(title)) {
title <- "Visual Model Comparison"
}
if (is.null(subtitle)) {
subtitle <- "Model 1: solid lines, Model 2: dashed lines"
}
# Layer helper: piecewise-constant discrete quantiles are rendered with
# geom_step (vertical risers, direction "mid"), continuous curves with
# geom_line
model_layer <- function(dat, lty, use_step) {
if (use_step) {
geom_step(
data = dat,
aes(x = .data$age, y = .data$value, color = .data$percentile),
direction = "mid",
linetype = lty,
linewidth = 0.6
)
} else {
geom_line(
data = dat,
aes(x = .data$age, y = .data$value, color = .data$percentile),
linetype = lty,
linewidth = 0.6
)
}
}
# Create plot
p <- ggplot() +
model_layer(plot_data_long[plot_data_long$model == "Model 1", ],
"solid", step1) +
model_layer(plot_data_long[plot_data_long$model == "Model 2", ],
"dashed", step2) +
scale_color_manual(values = rainbow(length(percentiles)),
labels = paste0(percentiles * 100, "%")) +
labs(
title = title,
subtitle = subtitle,
x = "Age",
y = "Score",
color = "Percentile"
) +
theme_minimal() +
theme(
plot.title = element_text(
hjust = 0.5,
size = 16,
face = "bold"
),
plot.subtitle = element_text(hjust = 0.5, size = 12),
axis.title = element_text(size = 12, face = "bold"),
axis.title.x = element_text(margin = margin(t = 10)),
axis.title.y = element_text(margin = margin(r = 10)),
axis.text = element_text(size = 10),
legend.position = "right",
legend.title = element_text(size = 12, face = "bold"),
legend.text = element_text(size = 10),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_line(color = "gray95")
)
# Information criteria
if (isTaylor(model1)) {
ideal.model <- model1$model$ideal.model
rss <- model1$model$subsets$rss[ideal.model]
n <- nrow(model1$data)
sigma2 <- rss / (nrow(model1$data) - ideal.model - 1) # residual variance
loglik <- -0.5 * n * (log(2 * pi) + log(sigma2) + 1)
AIC1 <- -2 * loglik + 2 * ideal.model
BIC1 <- model1$model$subsets$bic[ideal.model]
} else{
n_obs <- attr(model1$result, "N")
n_params <- length(model1$result$par)
log_likelihood <- -model1$result$value
AIC1 <- 2 * n_params - 2 * log_likelihood
BIC1 <- n_params * log(n_obs) - 2 * log_likelihood
}
if (isTaylor(model2)) {
ideal.model <- model2$model$ideal.model
rss <- model2$model$subsets$rss[ideal.model]
n <- nrow(model2$data)
sigma2 <- rss / (nrow(model2$data) - ideal.model - 1) # residual variance
loglik <- -0.5 * n * (log(2 * pi) + log(sigma2) + 1)
AIC2 <- -2 * loglik + 2 * ideal.model
BIC2 <- model2$model$subsets$bic[ideal.model]
} else{
n_obs <- attr(model2$result, "N")
n_params <- length(model2$result$par)
log_likelihood <- -model2$result$value
AIC2 <- 2 * n_params - 2 * log_likelihood
BIC2 <- n_params * log(n_obs) - 2 * log_likelihood
}
if (!is.null(score) & !is.null(age)) {
# Prepare data for manifest percentiles and fit statistics
data <- data.frame(age = age, score = score)
if (!is.null(weights)) {
data$w <- weights
} else{
data$w <- rep(1, length(age))
}
# Calculate groups for manifest percentiles
if (length(age) / length(unique(age)) > 50 &&
min(table(data$age)) > 30) {
data$group <- age
} else {
data$group <- getGroups(age)
}
# Calculate manifest percentiles
percentile.actual <- as.data.frame(do.call("rbind", lapply(split(
data, data$group
), function(df) {
c(age = mean(df$age),
weighted.quantile(df$score, probs = percentiles, weights = df$w))
})))
colnames(percentile.actual) <- c("age", paste0("P", percentiles * 100))
# Reshape manifest data
manifest_data_long <- data.frame(age = numeric(),
value = numeric(),
percentile = character())
for (i in 2:ncol(percentile.actual)) {
manifest_data_long <- rbind(
manifest_data_long,
data.frame(
age = percentile.actual$age,
value = percentile.actual[[i]],
percentile = names(percentile.actual)[i]
)
)
}
manifest_data_long$percentile <- factor(manifest_data_long$percentile,
levels = paste0("P", percentiles * 100))
# Add manifest percentiles to plot
p <- p + geom_point(
data = manifest_data_long,
aes(
x = .data$age,
y = .data$value,
color = .data$percentile
),
size = 2,
shape = 18
)
# Calculate fit statistics
if (is.null(weights)) {
data <- rankByGroup(data, raw = "score", group = "group")
} else{
data <- rankByGroup(data,
raw = "score",
group = "group",
weights = "w")
}
data$normValue <- 10 * (data$normValue - attributes(data)$scaleMean) / attributes(data)$scaleSD
# Get predictions for both models
if (isTaylor(model1)) {
data$fitted1 <- predictNorm(
data$score,
data$age,
model1,
minNorm = model1$model$minL1,
maxNorm = model1$model$maxL1
)
data$fitted1 <- 10 * (data$fitted1 - attributes(model1$data)$scaleMean) / attributes(model1$data)$scaleSD
} else if (isParametric(model1)) {
data$fitted1 <- predict(model1, data$age, data$score)
scaleMean <- attr(model1$result, "scaleMean")
scaleSD <- attr(model1$result, "scaleSD")
data$fitted1 <- 10 * (data$fitted1 - scaleMean) / scaleSD
}
if (isTaylor(model2)) {
data$fitted2 <- predictNorm(
data$score,
data$age,
model2,
minNorm = model2$model$minL1,
maxNorm = model2$model$maxL1
)
data$fitted2 <- 10 * (data$fitted2 - attributes(model2$data)$scaleMean) / attributes(model2$data)$scaleSD
} else if (isParametric(model2)) {
data$fitted2 <- predict(model2, data$age, data$score)
scaleMean <- attr(model2$result, "scaleMean")
scaleSD <- attr(model2$result, "scaleSD")
data$fitted2 <- 10 * (data$fitted2 - scaleMean) / scaleSD
}
# Calculate fit statistics
R2a <- cor(data$fitted1, data$normValue, use = "pairwise.complete.obs")^2
R2b <- cor(data$fitted2, data$normValue, use = "pairwise.complete.obs")^2
bias1 <- mean(data$fitted1 - data$normValue, na.rm = TRUE)
bias2 <- mean(data$fitted2 - data$normValue, na.rm = TRUE)
RMSE1 <- sqrt(mean((data$fitted1 - data$normValue)^2, na.rm = TRUE))
RMSE2 <- sqrt(mean((data$fitted2 - data$normValue)^2, na.rm = TRUE))
MAD1 <- mean(abs(data$fitted1 - data$normValue), na.rm = TRUE)
MAD2 <- mean(abs(data$fitted2 - data$normValue), na.rm = TRUE)
# Create and print summary table
fit_table <- data.frame(
Metric = c("R2", "Bias", "RMSE", "MAD", "AIC", "BIC"),
Model1 = c(R2a, bias1, RMSE1, MAD1, AIC1, BIC1),
Model2 = c(R2b, bias2, RMSE2, MAD2, AIC2, BIC2),
Difference = c(
R2b - R2a,
bias2 - bias1,
RMSE2 - RMSE1,
MAD2 - MAD1,
AIC2 - AIC1,
BIC2 - BIC1
)
)
# Round values
fit_table[, 2:4] <- round(fit_table[, 2:4], 4)
cat("\nModel Comparison Summary:\n")
cat("------------------------\n")
print(format(fit_table, justify = "right"), row.names = FALSE)
cat("\nNote: Difference = Model2 - Model1\n")
cat(" Fit indices are based on the manifest and fitted norm scores of both models.\n")
cat(" Scale metrics are T scores (scaleSD = 10)\n")
cat(" AIC and BIC should only be used when comparing models of the same type.\n")
} else{
# Create and print summary table
fit_table <- data.frame(
Metric = c("AIC", "BIC"),
Model1 = c(AIC1, BIC1),
Model2 = c(AIC2, BIC2),
Difference = c(AIC2 - AIC1, BIC2 - BIC1)
)
cat("\nModel Comparison Summary:\n")
cat("------------------------\n")
print(format(fit_table, justify = "right"), row.names = FALSE)
cat("\nNote: Difference = Model2 - Model1\n")
cat(" AIC and BIC should only be used when comparing models of the same type.\n")
}
return(p)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.