diff --git a/R/ROCplot.R b/R/ROCplot.R index ec6e2ae..54a71d1 100644 --- a/R/ROCplot.R +++ b/R/ROCplot.R @@ -203,10 +203,10 @@ setMethod("ROCplot", "list", if(numberDistinctClasses == 2) { plotData <- subset(plotData, class == distinctClasses[2]) - plotData[, comparisonName] <- paste(plotData[, comparisonName], " (AUC ", plotData[, "AUC"], ')', sep = '') + plotData[, comparisonName] <- paste(plotData[, comparisonName], " (AUC ", round(plotData[, "AUC"], 2), ')', sep = '') plotData[, comparisonName] <- factor(plotData[, comparisonName], levels = unique(plotData[, comparisonName])) } else { - plotData[, "class"] <- paste(plotData[, "class"], " (AUC ", plotData[, "AUC"], ')', sep = '') + plotData[, "class"] <- paste(plotData[, "class"], " (AUC ", round(plotData[, "AUC"], 2), ')', sep = '') plotData[, "class"] <- factor(plotData[, "class"], levels = unique(plotData[, "class"])) } } @@ -231,7 +231,7 @@ setMethod("ROCplot", "list", ROCplot <- ROCplot + ggplot2::xlab(xLabel) + ggplot2::ylab(yLabel) + ggplot2::ggtitle(plotTitle) + ggplot2::theme(axis.title = ggplot2::element_text(size = fontSizes[2]), plot.title = ggplot2::element_text(size = fontSizes[1], hjust = 0.5)) if(length(results) == 1 && showAUC == TRUE && numberDistinctClasses == 2) - ROCplot <- ROCplot + ggplot2::annotate("text", x = Inf, y = 0, label = paste("AUC =", plotData[1, "AUC"]), hjust = 1.1, size = fontSizes[2] * 5/14) + ggplot2::theme(legend.position = "none") + ROCplot <- ROCplot + ggplot2::annotate("text", x = Inf, y = 0, label = paste("AUC =", round(plotData[1, "AUC"], 2)), hjust = 1.1, size = fontSizes[2] * 5/14) + ggplot2::theme(legend.position = "none") if(length(results) > 1 && numberDistinctClasses > 2) ROCplot <- ROCplot + ggplot2::facet_wrap(ggplot2::vars(!!comparison)) diff --git a/R/calcPerformance.R b/R/calcPerformance.R index 6578d72..92f5052 100755 --- a/R/calcPerformance.R +++ b/R/calcPerformance.R @@ -50,7 +50,8 @@ #' \item{\code{"Micro F1"}: F1 score obtained by calculating the #' harmonic mean of micro precision and micro recall.} #' \item{\code{"Macro Precision"}: Sum of the ratios of the number of correct predictions in each -#' class to the number of samples predicted to be in each class, divided by the number of classes.} +#' class to the number of samples predicted to be in each class, divided by the number of classes. A class which is never +#' predicted has a precision of 0.} #' \item{\code{"Macro Recall"}: Sum of the ratios of the number of correct predictions #' in each class to the number of samples in each class, divided by the number of classes.} #' \item{\code{"Macro F1"}: F1 score obtained by calculating the harmonic mean of macro precision @@ -58,17 +59,21 @@ #' \item{\code{"Matthews Correlation Coefficient"}: Matthews Correlation Coefficient (MCC). A score #' between -1 and 1 indicating how concordant the predicted classes are to the actual classes. Only defined if #' there are two classes.} -#' \item{\code{"AUC"}: Area Under the Curve. An area ranging from 0 to 1, under the ROC.} +#' \item{\code{"AUC"}: Area Under the Curve. An area ranging from 0 to 1, under the ROC. For more than two classes, +#' the mean of the one-versus-rest areas. If a group of predictions lacks a sample of some class, its AUC is +#' \code{NaN}, with a warning.} #' \item{\code{"C-index"}: For survival data, the concordance index, for models which produce risk scores. Ranges from 0 to 1.} -#' \item{\code{"Sample C-index"}: Per-individual C-index.} +#' \item{\code{"Sample C-index"}: Per-individual C-index. For each sample, the proportion of comparable pairs it +#' belongs to which are concordant, with a pair of tied risk scores counting as half concordant, as for the C-index.} #' } #' #' @param actualOutcome A factor vector or survival information specifying each sample's known outcome. #' @param predictedOutcome A factor vector or survival information of the same length as \code{actualOutcome} specifying each sample's predicted outcome. #' @param grouping Default: \code{"permutation"}. If the cross-validation was k-fold, then this determines whether the metric will be calculated for samples #' grouped by permutation or by fold, if the value is \code{"fold"}. For small sample sizes, \code{"permutation"} would suit. But, for large sample sizes, -#' \code{"fold"} would be preferable, as class membership probabilities or risk scores are not directly comparable between folds. This setting makes -#' no difference to error or accuracy metrics, apart from their variability. +#' \code{"fold"} would be preferable, as class membership probabilities or risk scores are not directly comparable between folds. If +#' \code{"fold"}, the metric is calculated for each fold and the folds' values are averaged, giving one value for each permutation. +#' Metrics are not rounded. #' #' @return If \code{calcCVperformance} was run, an updated #' \code{\linkS4class{ClassifyResult}} object, with new metric values in the @@ -225,6 +230,8 @@ setMethod("calcCVperformance", "ClassifyResult", predictedOutcome <- factor(result@predictions[, "class"], levels = classLevels) actualOutcome <- factor(actualOutcomeOrdered, levels = classLevels, ordered = TRUE) performance <- .calcPerformance(actualOutcome, predictedOutcome, samples, performanceType, groupID) + if(!performanceType %in% c("Sample Error", "Sample Accuracy") && grepl(':', names(performance[["values"]])[1])) # Then average for each permutation. + performance[["values"]] <- .averagePermutations(performance[["values"]]) result@performance[[performance[["name"]]]] <- performance[["values"]] } } @@ -282,17 +289,22 @@ setMethod("calcCVperformance", "ClassifyResult", earlierEvent <- outer(times, times, '<') & iterationSurv[, "status"] == 1 higherRisk <- outer(iterationPredictions, iterationPredictions, '>') lowerRisk <- outer(iterationPredictions, iterationPredictions, '<') + tiedRisk <- outer(iterationPredictions, iterationPredictions, '==') # Concordant: the sample with the earlier event has the higher risk. Discordant: it has the lower risk. + # Tied: both have the same risk, which counts as half concordant, as for the C-index. concordant <- earlierEvent & higherRisk discordant <- earlierEvent & lowerRisk + tied <- earlierEvent & tiedRisk data.frame(sample = iterationSamples, concordant = rowSums(concordant) + colSums(concordant), - discordant = rowSums(discordant) + colSums(discordant)) + discordant = rowSums(discordant) + colSums(discordant), + tied = rowSums(tied) + colSums(tied)) }, actualOutcome, predictedOutcome, samples, SIMPLIFY = FALSE)) concordants <- rowsum(pairCounts[, "concordant"], pairCounts[, "sample"])[, 1] discordants <- rowsum(pairCounts[, "discordant"], pairCounts[, "sample"])[, 1] - Cindex <- round(concordants / (concordants + discordants), 2)[allSamples] + tieds <- rowsum(pairCounts[, "tied"], pairCounts[, "sample"])[, 1] + Cindex <- ((concordants + 0.5 * tieds) / (concordants + discordants + tieds))[allSamples] names(Cindex) <- allSamples Cindex[is.nan(Cindex)] <- NA # A censored individual with a time shorter than all events has no comparable pairs. return(list(name = performanceType, values = Cindex)) @@ -329,8 +341,13 @@ setMethod("calcCVperformance", "ClassifyResult", mean(classErrors / classSizes, na.rm = TRUE) }, actualOutcome, predictedOutcome, SIMPLIFY = FALSE)) } else if(performanceType == "AUC") { + missingClass <- sapply(actualOutcome, function(iterationClasses) any(table(iterationClasses) == 0)) + if(any(missingClass)) + warning(sum(missingClass), " of ", length(missingClass), " groups of predictions lack a sample of some class. ", + "Their AUC is NaN.") performanceValues <- unlist(mapply(function(iterationClasses, iterationPredictions) { + if(any(table(iterationClasses) == 0)) return(NaN) classesTable <- do.call(rbind, lapply(levels(iterationClasses), function(class) { rates <- .ROCrates(iterationPredictions[, class], iterationClasses == class) @@ -374,7 +391,9 @@ setMethod("calcCVperformance", "ClassifyResult", } if(performanceType %in% c("Macro Precision", "Macro F1")) { - macroP <- sum(truePositives / (truePositives + falsePositives)) / nrow(confusionMatrix) + classPrecisions <- truePositives / (truePositives + falsePositives) + classPrecisions[truePositives + falsePositives == 0] <- 0 # The class was never predicted. + macroP <- sum(classPrecisions) / nrow(confusionMatrix) if(performanceType == "Macro Precision") return(macroP) } if(performanceType %in% c("Macro Recall", "Macro F1")) diff --git a/R/crissCrossValidate.R b/R/crissCrossValidate.R index a243097..69333dc 100644 --- a/R/crissCrossValidate.R +++ b/R/crissCrossValidate.R @@ -156,7 +156,6 @@ crissCrossValidate <- function(measurements, outcomes, dimnames = list(paste("Select and Train", names(measurements)), paste("Predict", names(measurements))) ) - realPerformance <- round(realPerformance, 2) } else { # trainType == "modelTest" @@ -208,7 +207,6 @@ crissCrossValidate <- function(measurements, outcomes, dimnames = list(paste("Select", names(measurements)), paste("Cross-validate", names(measurements))) ) - realPerformance <- round(realPerformance, 2) } result <- list(real = realPerformance) # Store the results @@ -244,7 +242,6 @@ crissCrossValidate <- function(measurements, outcomes, dimnames = list(paste("Random Select", names(measurements)), paste("Cross-validate", names(measurements))) ) - randomPerformance <- round(randomPerformance, 2) result$random <- randomPerformance } @@ -294,7 +291,7 @@ crissCrossValidate <- function(measurements, outcomes, topMatrix <- matrix(topPerformance, nrow = 1, dimnames = list("TOP", names(top_measurements))) - result$top <- round(topMatrix, 2) + result$top <- topMatrix } # Store the parameters @@ -330,15 +327,16 @@ crissCrossValidate <- function(measurements, outcomes, #' A function to plot the output of the crissCrossValidate function. #' #' This function generates a heatmap of the cross-validation results from -#' \code{\link{crissCrossValidate}}. For \code{trainType = "modelTrain"}, the diagonal -#' (where the training set is the test set) is resubstitution and is hidden unless \code{showResubMetric = TRUE}. -#' For \code{trainType = "modelTest"}, the diagonal is cross-validation within each data set; it is shown and -#' outlined in black. +#' \code{\link{crissCrossValidate}}. The diagonal, where the features are chosen or the model is trained in the data set +#' that is tested, is hidden unless \code{showDiagonal = TRUE}. For \code{trainType = "modelTrain"}, it is +#' resubstitution. For \code{trainType = "modelTest"}, it is cross-validation within each data set. If shown, it is +#' outlined in black and a caption says how it was evaluated. #' #' @param crissCrossResult The output of the \code{\link{crissCrossValidate}} function. #' @param includeValues Logical. If \code{TRUE}, numeric values are printed on each tile. -#' @param showResubMetric Logical. If \code{FALSE}, the diagonal (resubstitution) cells of a \code{"modelTrain"} result -#' are set to \code{NA} and appear grayed-out or blank. Defaults to \code{FALSE}. +#' @param showDiagonal Logical. If \code{FALSE}, the diagonal cells are set to \code{NA} and appear grayed-out. +#' Defaults to \code{FALSE}. +#' @param showResubMetric Deprecated name of \code{showDiagonal}. #' @return A \code{ggplot} object, or a combined plot of two heatmaps if random features were evaluated. #' #' @import ggplot2 @@ -347,8 +345,14 @@ crissCrossValidate <- function(measurements, outcomes, #' @export crissCrossPlot <- function(crissCrossResult, includeValues = FALSE, - showResubMetric = FALSE) + showDiagonal = FALSE, + showResubMetric) { + if(!missing(showResubMetric)) + { + warning("'showResubMetric' is deprecated. Use 'showDiagonal' instead.") + showDiagonal <- showResubMetric + } params <- crissCrossResult[["params"]] scalebar_title <- params$performanceType # Results created before the diagonal was recorded have a resubstitution diagonal. @@ -360,7 +364,7 @@ crissCrossPlot <- function(crissCrossResult, # Convert to matrix if needed mat <- as.matrix(mat) - if (isResubstitution && !showResubMetric) { + if (!showDiagonal) { diag(mat) <- NA } @@ -389,7 +393,7 @@ crissCrossPlot <- function(crissCrossResult, ) + coord_fixed() - if (!isResubstitution || showResubMetric) { + if (showDiagonal) { gg <- gg + geom_tile(data = diagonal_df, aes(x = Var1, y = Var2), inherit.aes = FALSE, fill = NA, colour = "black", linewidth = 0.5) + labs(caption = paste("Diagonal:", diagonalText)) diff --git a/R/performancePlot.R b/R/performancePlot.R index 37b433d..9615f21 100755 --- a/R/performancePlot.R +++ b/R/performancePlot.R @@ -9,6 +9,11 @@ #' \code{characteristicsList[["x"]]} is one of \code{aggregate}, its values #' are averaged to a single number and a bar is plotted. #' +#' A dashed line shows the metric's expected value for predictions made by chance: 0.5 for AUC and C-index, 0 for +#' Matthews Correlation Coefficient and, with k classes, 1/k for accuracy, precision, recall and F1 metrics and 1 - 1/k for +#' error metrics. This is the value expected from predicting classes uniformly at random. No line is drawn for +#' other metrics. +#' #' @aliases performancePlot performancePlot,list-method #' @param results A list of \code{\link{ClassifyResult}} objects. #' @param aggregate A character vector of the levels of @@ -145,7 +150,7 @@ setMethod("performancePlot", "list", results <- lapply(results, function(result) calcCVperformance(result, metric)) } - ifelse(metric == "Matthews Correlation Coefficient", baseline <- 0, baseline <- 0.5) + baseline <- .chanceLevel(metric, results[[1]]) plotData <- do.call(rbind, mapply(function(result, index) { @@ -182,8 +187,8 @@ setMethod("performancePlot", "list", legendPosition <- ifelse(showLegend == TRUE, "right", "none") characteristicsList <- lapply(characteristicsList, rlang::sym) - performancePlot <- ggplot2::ggplot() + ggplot2::theme_classic() + ggplot2::theme(panel.border = ggplot2::element_rect(fill = NA)) + - ggplot2::geom_hline(yintercept = baseline, linetype = 2) + performancePlot <- ggplot2::ggplot() + ggplot2::theme_classic() + ggplot2::theme(panel.border = ggplot2::element_rect(fill = NA)) + if(!is.na(baseline)) performancePlot <- performancePlot + ggplot2::geom_hline(yintercept = baseline, linetype = 2) if(!is.null(yLimits) && rotate90 == FALSE) performancePlot <- performancePlot + ggplot2::coord_cartesian(ylim = yLimits) if("fillColour" %in% names(characteristicsList)) @@ -215,3 +220,17 @@ setMethod("performancePlot", "list", performancePlot }) + +# Expected value of a metric for predictions made by chance. For classes, predictions are uniformly random. +.chanceLevel <- function(metric, result) +{ + if(metric %in% c("AUC", "C-index", "Sample C-index")) return(0.5) + if(metric == "Matthews Correlation Coefficient") return(0) + outcome <- actualOutcome(result) + if(!is.factor(outcome)) return(NA) + nClasses <- length(levels(outcome)) + if(metric %in% c("Accuracy", "Balanced Accuracy", "Sample Accuracy", "Micro Precision", "Micro Recall", "Micro F1", + "Macro Precision", "Macro Recall", "Macro F1")) return(1 / nClasses) + if(metric %in% c("Error", "Balanced Error", "Sample Error")) return(1 - 1 / nClasses) + NA +} diff --git a/R/rankingPlot.R b/R/rankingPlot.R index 590939d..69f0a6a 100755 --- a/R/rankingPlot.R +++ b/R/rankingPlot.R @@ -9,7 +9,9 @@ #' overlap between all possible pairs of results. The second kind of summary is #' the pair-wise overlap of each level of the comparison factor that is not the #' reference level against the reference level. The overlaps are converted to -#' percentages and plotted as lineplots. +#' percentages and plotted as lineplots. The overlap of two rankings at a number of top features is the number of features +#' in both lists of top features, divided by the length of the shorter list (which is shorter than the number of top +#' features only if a ranking has fewer features than it). #' #' If \code{comparison} is \code{"within"}, then the feature selection overlaps #' are compared within a particular analysis. The result will inform how stable @@ -60,7 +62,8 @@ #' @param xLabelPositions Locations where to put labels on the x-axis. #' @param yMax The maximum value of the percentage to plot. #' @param title An overall title for the plot. -#' @param yLabel Label to be used for the y-axis of overlap percentages. +#' @param yLabel Label to be used for the y-axis of overlap percentages. By default, it states that the percentage of +#' top features in common is shown. #' @param margin The margin to have around the plot. #' @param showLegend If \code{TRUE}, a legend is plotted next to the plot. If #' FALSE, it is hidden. @@ -121,7 +124,7 @@ setMethod("rankingPlot", "list", fonts = c(24, 16, 12, 12, 12, 16)), lineColours = NULL, xLabelPositions = seq(10, 100, 10), yMax = 100, title = if(comparison[1] == "within") "Feature Ranking Stability" else "Feature Ranking Commonality", - yLabel = if(is.null(referenceLevel)) "Average Common Features (%)" else paste("Average Common Features with", referenceLevel, "(%)"), + yLabel = if(is.null(referenceLevel)) "Average Top Features in Common (%)" else paste("Average Top Features in Common with", referenceLevel, "(%)"), margin = grid::unit(c(1, 1, 1, 1), "lines"), showLegend = TRUE, parallelParams = bpparam()) { @@ -184,7 +187,7 @@ setMethod("rankingPlot", "list", { sapply(topRanked, function(top) { - length(intersect(head(features, top), head(other, top))) / top * 100 + .topOverlap(features, other, top) }) }) }, rankedList[1:(length(rankedList) - 1)], 1:(length(rankedList) - 1), SIMPLIFY = FALSE))) @@ -234,7 +237,7 @@ setMethod("rankingPlot", "list", { sapply(topRanked, function(top) { - length(intersect(head(rankings, top), head(otherRanked, top))) / top * 100 + .topOverlap(rankings, otherRanked, top) }) }) }))) @@ -291,3 +294,12 @@ setMethod("rankingPlot", "list", overlapPlot }) + +# Percentage of the top features of two rankings which are in common. The denominator is the length of the shorter +# list of top features, so that a ranking with fewer features than top is not penalised. +.topOverlap <- function(ranking, otherRanking, top) +{ + ranking <- head(ranking, top) + otherRanking <- head(otherRanking, top) + length(intersect(ranking, otherRanking)) / min(length(ranking), length(otherRanking)) * 100 +} diff --git a/R/runTest.R b/R/runTest.R index 58f02d1..96cf8b5 100644 --- a/R/runTest.R +++ b/R/runTest.R @@ -286,7 +286,7 @@ input data. Autmomatically reducing to smaller number.") if(!is.null(ncol(predictedOutcome))) predictedOutcome <- predictedOutcome[, na.omit(match(c("class", "risk"), colnames(predictedOutcome)))] - performanceChanges <- round(performancesWithoutEach - calcExternalPerformance(outcomeTest, predictedOutcome, performanceType), 2) + performanceChanges <- performancesWithoutEach - calcExternalPerformance(outcomeTest, predictedOutcome, performanceType) if(is.null(S4Vectors::mcols(measurementsTrain)) || !any(c("assay", "feature") %in% colnames(S4Vectors::mcols(measurementsTrain)))) { diff --git a/R/selectionPlot.R b/R/selectionPlot.R index d4d2b0e..96aa8b6 100755 --- a/R/selectionPlot.R +++ b/R/selectionPlot.R @@ -27,7 +27,7 @@ #' #' Calculating all pair-wise set overlaps can be time-consuming. This stage can #' be done on multiple CPUs by providing the relevant options to -#' \code{parallelParams}. The percentage is calculated as the intersection of +#' \code{parallelParams}. The percentage is the Jaccard similarity: the intersection of #' two sets of features divided by the union of the sets, multiplied by 100. #' #' For the feature selection size mode, \code{binsList} is used to create bins @@ -153,7 +153,7 @@ setMethod("selectionPlot", "list", comparison = "within", referenceLevel = NULL, characteristicsList = list(x = "auto"), coloursList = list(), alpha = 1, orderingList = list(), binsList = list(), yMax = 100, densityStyle = c("box", "violin"), fontSizes = c(24, 16, 12, 16), title = if(comparison == "within") "Feature Selection Stability" else if(comparison == "size") "Feature Selection Size" else if(comparison == "importance") "Variable Importance" else "Feature Selection Commonality", - yLabel = if(is.null(referenceLevel) && !comparison %in% c("size", "importance")) "Common Features (%)" else if(comparison == "size") "Set Size" else if(comparison == "importance") tail(names(results[[1]]@importance), 1) else paste("Common Features with", referenceLevel, "(%)"), + yLabel = if(is.null(referenceLevel) && !comparison %in% c("size", "importance")) "Jaccard Similarity (%)" else if(comparison == "size") "Set Size" else if(comparison == "importance") tail(names(results[[1]]@importance), 1) else paste("Jaccard Similarity with", referenceLevel, "(%)"), margin = grid::unit(c(1, 1, 1, 1), "lines"), rotate90 = FALSE, showLegend = TRUE, parallelParams = bpparam()) { if(!requireNamespace("ggplot2", quietly = TRUE)) diff --git a/R/utilities.R b/R/utilities.R index 82bb46f..8fbef86 100644 --- a/R/utilities.R +++ b/R/utilities.R @@ -423,7 +423,7 @@ splitsTestInfo <- function(samplesSplits = c("k-Fold", "Permute k-Fold", "Permut (FPR[current] - FPR[previous]) * TPR[current]) # Line went either up or right, but not both. areaSum <- 0 for(newArea in newAreas) areaSum <- areaSum + newArea # Same order of addition as the trapezoid sum. - data.frame(classTable, AUC = round(areaSum, 2), check.names = FALSE) + data.frame(classTable, AUC = areaSum, check.names = FALSE) })) } diff --git a/man/calcPerformance.Rd b/man/calcPerformance.Rd index 970dcb0..d89641c 100644 --- a/man/calcPerformance.Rd +++ b/man/calcPerformance.Rd @@ -77,7 +77,8 @@ Must be one of the following options: \item{\code{"Micro F1"}: F1 score obtained by calculating the harmonic mean of micro precision and micro recall.} \item{\code{"Macro Precision"}: Sum of the ratios of the number of correct predictions in each -class to the number of samples predicted to be in each class, divided by the number of classes.} +class to the number of samples predicted to be in each class, divided by the number of classes. A class which is never +predicted has a precision of 0.} \item{\code{"Macro Recall"}: Sum of the ratios of the number of correct predictions in each class to the number of samples in each class, divided by the number of classes.} \item{\code{"Macro F1"}: F1 score obtained by calculating the harmonic mean of macro precision @@ -85,17 +86,21 @@ and macro recall.} \item{\code{"Matthews Correlation Coefficient"}: Matthews Correlation Coefficient (MCC). A score between -1 and 1 indicating how concordant the predicted classes are to the actual classes. Only defined if there are two classes.} -\item{\code{"AUC"}: Area Under the Curve. An area ranging from 0 to 1, under the ROC.} +\item{\code{"AUC"}: Area Under the Curve. An area ranging from 0 to 1, under the ROC. For more than two classes, + the mean of the one-versus-rest areas. If a group of predictions lacks a sample of some class, its AUC is + \code{NaN}, with a warning.} \item{\code{"C-index"}: For survival data, the concordance index, for models which produce risk scores. Ranges from 0 to 1.} -\item{\code{"Sample C-index"}: Per-individual C-index.} +\item{\code{"Sample C-index"}: Per-individual C-index. For each sample, the proportion of comparable pairs it + belongs to which are concordant, with a pair of tied risk scores counting as half concordant, as for the C-index.} }} \item{result}{An object of class \code{\link{ClassifyResult}}.} \item{grouping}{Default: \code{"permutation"}. If the cross-validation was k-fold, then this determines whether the metric will be calculated for samples grouped by permutation or by fold, if the value is \code{"fold"}. For small sample sizes, \code{"permutation"} would suit. But, for large sample sizes, -\code{"fold"} would be preferable, as class membership probabilities or risk scores are not directly comparable between folds. This setting makes -no difference to error or accuracy metrics, apart from their variability.} +\code{"fold"} would be preferable, as class membership probabilities or risk scores are not directly comparable between folds. If +\code{"fold"}, the metric is calculated for each fold and the folds' values are averaged, giving one value for each permutation. +Metrics are not rounded.} \item{resultsList}{A list of modelling results. Each element must be of type \code{\link{ClassifyResult}}.} diff --git a/man/crissCrossPlot.Rd b/man/crissCrossPlot.Rd index a4a7c97..fed8f3c 100644 --- a/man/crissCrossPlot.Rd +++ b/man/crissCrossPlot.Rd @@ -7,7 +7,8 @@ crissCrossPlot( crissCrossResult, includeValues = FALSE, - showResubMetric = FALSE + showDiagonal = FALSE, + showResubMetric ) } \arguments{ @@ -15,16 +16,18 @@ crissCrossPlot( \item{includeValues}{Logical. If \code{TRUE}, numeric values are printed on each tile.} -\item{showResubMetric}{Logical. If \code{FALSE}, the diagonal (resubstitution) cells of a \code{"modelTrain"} result -are set to \code{NA} and appear grayed-out or blank. Defaults to \code{FALSE}.} +\item{showDiagonal}{Logical. If \code{FALSE}, the diagonal cells are set to \code{NA} and appear grayed-out. +Defaults to \code{FALSE}.} + +\item{showResubMetric}{Deprecated name of \code{showDiagonal}.} } \value{ A \code{ggplot} object, or a combined plot of two heatmaps if random features were evaluated. } \description{ This function generates a heatmap of the cross-validation results from -\code{\link{crissCrossValidate}}. For \code{trainType = "modelTrain"}, the diagonal -(where the training set is the test set) is resubstitution and is hidden unless \code{showResubMetric = TRUE}. -For \code{trainType = "modelTest"}, the diagonal is cross-validation within each data set; it is shown and -outlined in black. +\code{\link{crissCrossValidate}}. The diagonal, where the features are chosen or the model is trained in the data set +that is tested, is hidden unless \code{showDiagonal = TRUE}. For \code{trainType = "modelTrain"}, it is +resubstitution. For \code{trainType = "modelTest"}, it is cross-validation within each data set. If shown, it is +outlined in black and a caption says how it was evaluated. } diff --git a/man/performancePlot.Rd b/man/performancePlot.Rd index dc83a46..00a0d43 100644 --- a/man/performancePlot.Rd +++ b/man/performancePlot.Rd @@ -102,6 +102,11 @@ object, they are plotted as a box plot or, if \code{densityStyle} is \code{"violin"}, a violin plot. If a result's value of \code{characteristicsList[["x"]]} is one of \code{aggregate}, its values are averaged to a single number and a bar is plotted. + +A dashed line shows the metric's expected value for predictions made by chance: 0.5 for AUC and C-index, 0 for +Matthews Correlation Coefficient and, with k classes, 1/k for accuracy, precision, recall and F1 metrics and 1 - 1/k for +error metrics. This is the value expected from predicting classes uniformly at random. No line is drawn for +other metrics. } \examples{ diff --git a/man/rankingPlot.Rd b/man/rankingPlot.Rd index fbebcb7..e290a99 100644 --- a/man/rankingPlot.Rd +++ b/man/rankingPlot.Rd @@ -22,8 +22,8 @@ yMax = 100, title = if (comparison[1] == "within") "Feature Ranking Stability" else "Feature Ranking Commonality", - yLabel = if (is.null(referenceLevel)) "Average Common Features (\%)" else - paste("Average Common Features with", referenceLevel, "(\%)"), + yLabel = if (is.null(referenceLevel)) "Average Top Features in Common (\%)" else + paste("Average Top Features in Common with", referenceLevel, "(\%)"), margin = grid::unit(c(1, 1, 1, 1), "lines"), showLegend = TRUE, parallelParams = bpparam() @@ -80,7 +80,8 @@ palette will be used.} \item{title}{An overall title for the plot.} -\item{yLabel}{Label to be used for the y-axis of overlap percentages.} +\item{yLabel}{Label to be used for the y-axis of overlap percentages. By default, it states that the percentage of +top features in common is shown.} \item{margin}{The margin to have around the plot.} @@ -103,7 +104,9 @@ commonality are possible to analyse. One summary is the average pair-wise overlap between all possible pairs of results. The second kind of summary is the pair-wise overlap of each level of the comparison factor that is not the reference level against the reference level. The overlaps are converted to -percentages and plotted as lineplots. +percentages and plotted as lineplots. The overlap of two rankings at a number of top features is the number of features +in both lists of top features, divided by the length of the shorter list (which is shorter than the number of top +features only if a ranking has fewer features than it). } \details{ If \code{comparison} is \code{"within"}, then the feature selection overlaps diff --git a/man/selectionPlot.Rd b/man/selectionPlot.Rd index 6078150..8f3f9c3 100644 --- a/man/selectionPlot.Rd +++ b/man/selectionPlot.Rd @@ -24,9 +24,9 @@ == "size") "Feature Selection Size" else if (comparison == "importance") "Variable Importance" else "Feature Selection Commonality", yLabel = if (is.null(referenceLevel) && !comparison \%in\% c("size", "importance")) - "Common Features (\%)" else if (comparison == "size") "Set Size" else if (comparison - == "importance") tail(names(results[[1]]@importance), 1) else - paste("Common Features with", referenceLevel, "(\%)"), + "Jaccard Similarity (\%)" else if (comparison == "size") "Set Size" else if + (comparison == "importance") tail(names(results[[1]]@importance), 1) else + paste("Jaccard Similarity with", referenceLevel, "(\%)"), margin = grid::unit(c(1, 1, 1, 1), "lines"), rotate90 = FALSE, showLegend = TRUE, @@ -145,7 +145,7 @@ features being selected by different classifications. Calculating all pair-wise set overlaps can be time-consuming. This stage can be done on multiple CPUs by providing the relevant options to -\code{parallelParams}. The percentage is calculated as the intersection of +\code{parallelParams}. The percentage is the Jaccard similarity: the intersection of two sets of features divided by the union of the sets, multiplied by 100. For the feature selection size mode, \code{binsList} is used to create bins diff --git a/tests/testthat/test-crisscross.R b/tests/testthat/test-crisscross.R index 216c048..893a776 100644 --- a/tests/testthat/test-crisscross.R +++ b/tests/testthat/test-crisscross.R @@ -37,7 +37,7 @@ test_that("the modelTest diagonal is cross-validation within the data set", { crossValidated <- crossValidate(first$measurements, first$classes, nFeatures = 20, selectionMethod = "t-test", classifier = "DLDA", multiViewMethod = "none", nFolds = 5, nRepeats = 3) expect_equal(result[["real"]][1, 1], - round(mean(performance(calcCVperformance(crossValidated, "Balanced Accuracy"))[["Balanced Accuracy"]]), 2)) + mean(performance(calcCVperformance(crossValidated, "Balanced Accuracy"))[["Balanced Accuracy"]])) }) test_that("performance type and outcome type are resolved for every data set", { @@ -66,5 +66,12 @@ test_that("random features work with several values of nFeatures, and the plot i result[["random"]] <- NULL plotted <- crissCrossPlot(result) expect_s3_class(plotted, "ggplot") - expect_match(plotted[["labels"]][["caption"]], "cross-validation") + expect_true(all(is.na(plotted$data[plotted$data[, "Var1"] == rownames(result[["real"]])[1] & + plotted$data[, "Var2"] == colnames(result[["real"]])[1], "value"]))) + expect_null(plotted[["labels"]][["caption"]]) + withDiagonal <- crissCrossPlot(result, showDiagonal = TRUE) + expect_match(withDiagonal[["labels"]][["caption"]], "cross-validation") + expect_false(anyNA(withDiagonal$data[, "value"])) + expect_warning(oldName <- crissCrossPlot(result, showResubMetric = TRUE), "deprecated") + expect_equal(oldName$data, withDiagonal$data) }) diff --git a/tests/testthat/test-metrics.R b/tests/testthat/test-metrics.R index 4dd6d50..0c93228 100644 --- a/tests/testthat/test-metrics.R +++ b/tests/testthat/test-metrics.R @@ -34,13 +34,13 @@ makeRiskResult <- function(nSamples = 30, nPermutations = 3, seed = 2) list(function(oracle){}), NULL, S4Vectors::DataFrame(predictions), outcome) } -# Sample-wise C-index computed pair by pair. Tied times or tied risks count as neither concordant nor discordant. +# Sample-wise C-index computed pair by pair. Pairs with tied times are not comparable; tied risks count as half concordant. referenceSampleCindex <- function(result) { predictions <- as.data.frame(predictions(result)) outcome <- as.matrix(actualOutcome(result)) rownames(outcome) <- sampleNames(result) - counts <- matrix(0, length(sampleNames(result)), 2, dimnames = list(sampleNames(result), c("concordant", "discordant"))) + counts <- matrix(0, length(sampleNames(result)), 2, dimnames = list(sampleNames(result), c("concordant", "comparable"))) for(permutationPredictions in split(predictions, predictions[, "permutation"])) { for(sampleID in permutationPredictions[, "sample"]) @@ -51,18 +51,18 @@ referenceSampleCindex <- function(result) events <- outcome[c(sampleID, otherID), "status"] risks <- permutationPredictions[match(c(sampleID, otherID), permutationPredictions[, "sample"]), "risk"] earlier <- which.min(times) - if(times[1] == times[2] || events[earlier] != 1 || risks[1] == risks[2]) next - concordant <- risks[earlier] > risks[-earlier] - counts[sampleID, ] <- counts[sampleID, ] + c(concordant, !concordant) + if(times[1] == times[2] || events[earlier] != 1) next + concordant <- if(risks[1] == risks[2]) 0.5 else as.numeric(risks[earlier] > risks[-earlier]) + counts[sampleID, ] <- counts[sampleID, ] + c(concordant, 1) } } } - Cindex <- round(counts[, "concordant"] / rowSums(counts), 2) + Cindex <- counts[, "concordant"] / counts[, "comparable"] Cindex[is.nan(Cindex)] <- NA Cindex } -# AUC of each class by the trapezoid rule over the ROC curve, rounded and then averaged over classes. +# AUC of each class by the trapezoid rule over the ROC curve, averaged over classes. referenceAUC <- function(classes, scores) { mean(sapply(levels(classes), function(class) @@ -70,7 +70,7 @@ referenceAUC <- function(classes, scores) thresholds <- sort(unique(scores[, class]), decreasing = TRUE) TPR <- c(0, sapply(thresholds, function(threshold) mean(scores[classes == class, class] >= threshold))) FPR <- c(0, sapply(thresholds, function(threshold) mean(scores[classes != class, class] >= threshold))) - round(sum(diff(FPR) * (TPR[-1] + TPR[-length(TPR)]) / 2), 2) + sum(diff(FPR) * (TPR[-1] + TPR[-length(TPR)]) / 2) })) } @@ -82,6 +82,17 @@ test_that("sample-wise C-index matches the pair-by-pair definition, including sa expect_identical(names(values), sampleNames(result)) }) +test_that("sample-wise C-index counts tied risks as half concordant and is not rounded", { + outcome <- survival::Surv(c(1, 2, 3), c(1, 1, 1)) + predictions <- S4Vectors::DataFrame(sample = c("a", "b", "c"), permutation = 1, fold = 1, risk = c(2, 2, 1)) + result <- ClassifyResult(S4Vectors::DataFrame(characteristic = "Assay Name", value = "x"), c("a", "b", "c"), "g1", + list("g1"), list("g1"), list(function(oracle){}), NULL, predictions, outcome) + values <- performance(calcCVperformance(result, "Sample C-index"))[["Sample C-index"]] + # Pairs: a-b tied (1/2), a-c concordant, b-c concordant. + expect_equal(unname(values), c(0.75, 0.75, 1)) + expect_equal(survival::concordance(outcome ~ I(-c(2, 2, 1)))$concordance, 2.5 / 3) +}) + test_that("sample-wise C-index is NA for a sample which was never predicted", { result <- makeRiskResult() result@predictions <- result@predictions[result@predictions[, "sample"] != "s5", ] @@ -118,11 +129,52 @@ test_that("AUC with three classes is the mean of one-versus-rest AUCs", { expect_equal(unname(calcExternalPerformance(classes, scores, "AUC")), referenceAUC(classes, scores)) }) -test_that("AUC fails clearly if a class is absent from a group", { +test_that("AUC is NaN with a warning if a class is absent from a group", { result <- makeScoresResult(nSamples = 8, nPermutations = 1, nFolds = 2) result@predictions <- result@predictions[order(as.character(actualOutcome(result))[match(result@predictions[, "sample"], sampleNames(result))]), ] result@predictions[, "fold"] <- rep(1:2, each = 4) # Each fold has samples of only one class. - expect_error(calcCVperformance(result, "AUC", grouping = "fold"), "missing rates") + expect_warning(AUC <- performance(calcCVperformance(result, "AUC", grouping = "fold"))[["AUC"]], "2 of 2 groups") + expect_true(is.nan(AUC[["1"]])) + byPermutation <- performance(calcCVperformance(result, "AUC"))[["AUC"]] # Each permutation has both classes. + expect_equal(unname(byPermutation), referenceAUC(actualOutcome(result)[match(result@predictions[, "sample"], sampleNames(result))], + as.data.frame(result@predictions[, c("No", "Yes")]))) +}) + +test_that("AUC is not rounded", { + set.seed(7) + classes <- factor(rep(c("No", "Yes"), 13)) + scores <- data.frame(No = runif(26)) + scores[, "Yes"] <- 1 - scores[, "No"] + value <- unname(calcExternalPerformance(classes, scores, "AUC")) + expect_equal(value, referenceAUC(classes, scores)) + expect_false(isTRUE(all.equal(value, round(value, 2)))) +}) + +test_that("macro precision and F1 count a class which is never predicted as precision 0", { + actual <- factor(c("A", "A", "B", "B", "C", "C")) + predicted <- factor(c("A", "A", "B", "A", "B", "B"), levels = levels(actual)) # C is never predicted. + values <- calcExternalPerformance(actual, predicted, c("Macro Precision", "Macro Recall", "Macro F1")) + precision <- mean(c(2 / 3, 1 / 3, 0)) + recall <- mean(c(1, 1 / 2, 0)) + expect_equal(values[["Macro Precision"]], precision) + expect_equal(values[["Macro Recall"]], recall) + expect_equal(values[["Macro F1"]], 2 * precision * recall / (precision + recall)) +}) + +test_that("grouping by fold averages balanced accuracy and other class metrics within each permutation", { + result <- makeScoresResult(nPermutations = 3) + predictions <- as.data.frame(predictions(result)) + classes <- actualOutcome(result)[match(predictions[, "sample"], sampleNames(result))] + for(metric in c("Balanced Accuracy", "Macro F1")) + { + byFold <- performance(calcCVperformance(result, metric, grouping = "fold"))[[metric]] + expect_identical(names(byFold), as.character(1:3)) + rows <- which(predictions[, "permutation"] == 2) + foldValues <- sapply(split(rows, predictions[rows, "fold"]), function(foldRows) + unname(calcExternalPerformance(classes[foldRows], predictions[foldRows, "class"], metric))) + expect_equal(byFold[["2"]], mean(foldValues)) + } + expect_length(performance(calcCVperformance(result, "Sample Accuracy", grouping = "fold"))[["Sample Accuracy"]], 40) }) test_that("AUC of a realistic result takes well under a second per hundred permutations", { diff --git a/tests/testthat/test-plots.R b/tests/testthat/test-plots.R index 0fb97b7..4c2b663 100644 --- a/tests/testthat/test-plots.R +++ b/tests/testthat/test-plots.R @@ -101,6 +101,22 @@ test_that("performancePlot uses the user's yLimits when rotated", { expect_false(anyNA(rotated$layers[[2]]$data[, "Assay Name"])) }) +test_that("performancePlot draws the chance level of the metric", { + results <- lapply(list(makePlotResult("x"), makePlotResult("y", seed = 2)), calcCVperformance, performanceTypes = "Balanced Accuracy") + chanceLine <- function(plot) unname(unlist(lapply(plot$layers, function(layer) if(is(layer$geom, "GeomHline")) layer$data$yintercept))) + nClasses <- length(levels(actualOutcome(results[[1]]))) + expect_equal(chanceLine(performancePlot(results)), 1 / nClasses) + expect_equal(chanceLine(suppressWarnings(performancePlot(results, metric = "Balanced Error"))), 1 - 1 / nClasses) + expect_equal(ClassifyR:::.chanceLevel("AUC", results[[1]]), 0.5) + expect_equal(ClassifyR:::.chanceLevel("Matthews Correlation Coefficient", results[[1]]), 0) + expect_true(is.na(ClassifyR:::.chanceLevel("Unknown", results[[1]]))) +}) + +test_that("ranking overlap divides by the length of the shorter list of top features", { + expect_equal(ClassifyR:::.topOverlap(paste0("g", 1:3), paste0("g", c(1, 2, 9, 10)), 5), 2 / 3 * 100) + expect_equal(ClassifyR:::.topOverlap(paste0("g", 1:10), paste0("g", c(1, 2, 11:18)), 5), 2 / 5 * 100) +}) + test_that("rankingPlot uses its fonts, row and column characteristics, ordering and short rankings", { results <- list(makePlotResult("x", classifier = "DLDA", selection = "t-test"), makePlotResult("x", classifier = "SVM", selection = "t-test", seed = 2),