Potrebbe valere la pena aggiungere un altro esempio, forse più semplice, all'eccellente risposta di Stephen.
Consideriamo un test medico, il cui risultato è normalmente distribuito, sia nelle persone malate che in quelle sane, con diversi parametri ovviamente (ma per semplicità, assumiamo l'omoscedasticità, cioè che la varianza sia la stessa):Indichiamo la prevalenza della malattia con (cioè ), quindi questo, insieme a quanto sopra, che sono essenzialmente distribuzioni condizionate, specifica completamente la distribuzione articolare.T∣D⊖∼N(μ−,σ2)T∣D⊕∼N(μ+,σ2).
pD⊕∼Bern(p)
Quindi la matrice di confusione con soglia (cioè quelli con risultati del test sopra sono classificati come malati) èbb⎛⎝⎜T⊕T⊖D⊕p(1−Φ+(b))pΦ+(b)D⊖(1−p)(1−Φ−(b))(1−p)Φ−(b)⎞⎠⎟.
Approccio basato sulla precisione
La precisione èp(1−Φ+(b))+(1−p)Φ−(b),
prendiamo la sua derivata wrt , impostiamola uguale a 0, moltiplichiamo con e riordiniamo un po ': Il primo termine non può essere zero, quindi l'unico modo in cui il prodotto può essere zero è se il secondo termine è zero:b1πσ2−−−−√−pφ+(b)+φ−(b)−pφ−(b)=0e−(b−μ−)22σ2[(1−p)−pe−2b(μ−−μ+)+(μ2+−μ2−)2σ2]=0
(1−p)−pe−2b(μ−−μ+)+(μ2+−μ2−)2σ2=0−2b(μ−−μ+)+(μ2+−μ2−)2σ2=log1−pp2b(μ+−μ−)+(μ2−−μ2+)=2σ2log1−pp
Quindi la soluzione èb∗=(μ2+−μ2−)+2σ2log1−pp2(μ+−μ−)=μ++μ−2+σ2μ+−μ−log1−pp.
Si noti che questo - ovviamente - non dipende dai costi.
Se le classi sono bilanciate, l'ottimale è la media dei valori medi dei test nelle persone malate e sane, altrimenti viene spostato in base allo squilibrio.
Approccio basato sui costi
Utilizzando la notazione di Stephen, il costo complessivo previsto èPrendi il suo derivato wrt e impostalo uguale a zero:c++p(1−Φ+(b))+c−+(1−p)(1−Φ−(b))+c+−pΦ+(b)+c−−(1−p)Φ−(b).
b−c++pφ+(b)−c−+(1−p)φ−(b)+c+−pφ+(b)+c−−(1−p)φ−(b)==φ+(b)p(c+−−c++)+φ−(b)(1−p)(c−−−c−+)==φ+(b)pc+d−φ−(b)(1−p)c−d=0,
usando la notazione che ho introdotto nei miei commenti sotto la risposta di Stephen, vale a dire, e .c+d=c+−−c++c−d=c−+−c−−
La soglia ottimale è quindi data dalla soluzione dell'equazioneDue cose da notare qui:φ+(b)φ−(b)=(1−p)c−dpc+d.
- Questo risultato è totalmente generico e funziona per qualsiasi distribuzione dei risultati del test, non solo normale. ( in quel caso ovviamente significa la funzione di densità di probabilità della distribuzione, non la densità normale.)φ
- Qualunque sia la soluzione per , è sicuramente una funzione di . (Cioè, vediamo immediatamente quanto contano i costi, oltre allo squilibrio di classe!)b(1−p)c−dpc+d
Sarei davvero interessato a vedere se questa equazione ha una soluzione generica per (parametrizzata da s), ma sarei sorpreso.bφ
Tuttavia, possiamo risolverlo normalmente! s annulla sul lato sinistro, quindi abbiamo quindi la soluzione è2πσ2−−−−√e−12((b−μ+)2σ2−(b−μ−)2σ2)=(1−p)c−dpc+d(b−μ−)2−(b−μ+)2=2σ2log(1−p)c−dpc+d2b(μ+−μ−)+(μ2−−μ2+)=2σ2log(1−p)c−dpc+d
b∗=(μ2+−μ2−)+2σ2log(1−p)c−dpc+d2(μ+−μ−)=μ++μ−2+σ2μ+−μ−log(1−p)c−dpc+d.
(Confrontalo con il risultato precedente! Vediamo che sono uguali se e solo se , cioè le differenze nel costo di classificazione errata rispetto al costo di una corretta classificazione sono le stesse in malattia e salute persone.)c−d=c+d
Una breve dimostrazione
Diciamo (è abbastanza naturale dal punto di vista medico) e che (possiamo sempre ottenerlo dividendo i costi con , ovvero misurando tutti i costi in unità). Diciamo che la prevalenza è . Supponiamo anche che , e .c−−=0c++=1c++c++p=0.2μ−=9.5μ+=10.5σ=1
In questo caso:
library( data.table )
library( lattice )
cminusminus <- 0
cplusplus <- 1
p <- 0.2
muminus <- 9.5
muplus <- 10.5
sigma <- 1
res <- data.table( expand.grid( b = seq( 6, 17, 0.1 ),
cplusminus = c( 1, 5, 10, 50, 100 ),
cminusplus = c( 2, 5, 10, 50, 100 ) ) )
res$cost <- cplusplus*p*( 1-pnorm( res$b, muplus, sigma ) ) +
res$cplusminus*(1-p)*(1-pnorm( res$b, muminus, sigma ) ) +
res$cminusplus*p*pnorm( res$b, muplus, sigma ) +
cminusminus*(1-p)*pnorm( res$b, muminus, sigma )
xyplot( cost ~ b | factor( cminusplus ), groups = cplusminus, ylim = c( -1, 22 ),
data = res, type = "l", xlab = "Threshold",
ylab = "Expected overall cost", as.table = TRUE,
abline = list( v = (muplus+muminus)/2+
sigma^2/(muplus-muminus)*log((1-p)/p) ),
strip = strip.custom( var.name = expression( {"c"^{"+"}}["-"] ),
strip.names = c( TRUE, TRUE ) ),
auto.key = list( space = "right", points = FALSE, lines = TRUE,
title = expression( {"c"^{"-"}}["+"] ) ),
panel = panel.superpose, panel.groups = function( x, y, col.line, ... ) {
panel.xyplot( x, y, col.line = col.line, ... )
panel.points( x[ which.min( y ) ], min( y ), pch = 19, col = col.line )
} )
Il risultato è (i punti indicano il costo minimo e la linea verticale mostra la soglia ottimale con l'approccio basato sulla precisione):
Possiamo benissimo vedere come l'ottimizzazione basata sui costi possa essere diversa dall'ottimizzazione basata sull'accuratezza. È istruttivo riflettere sul perché: se è più costoso classificare un malato erroneamente sano rispetto al contrario ( è alto, è basso) rispetto al la soglia scende, poiché preferiamo classificare più facilmente nella categoria malato, d'altra parte, se è più costoso classificare un popolo sano malato erroneamente rispetto al contrario ( è basso, è alto) rispetto alla soglia, poiché preferiamo classificare più facilmente nella categoria sana. (Controlla questi sulla figura!)c+−c−+c+−c−+
Un esempio di vita reale
Diamo un'occhiata a un esempio empirico, anziché a una derivazione teorica. Questo esempio sarà sostanzialmente diverso da due aspetti:
- Invece di assumere la normalità, utilizzeremo semplicemente i dati empirici senza tale ipotesi.
- Invece di utilizzare un singolo test e i suoi risultati nelle sue unità, useremo diversi test (e li combineremo con una regressione logistica). La soglia verrà data alla probabilità prevista finale. Questo è in realtà l'approccio preferito, vedi il capitolo 19 - Diagnosi - nel BBR di Frank Harrell .
Il set di dati ( acath
dal pacchetto Hmisc
) proviene dalla banca dati delle malattie cardiovascolari della Duke University e contiene se il paziente ha avuto una coronaropatia significativa, come valutato dal cateterismo cardiaco, questo sarà il nostro gold standard, cioè il vero stato della malattia e il "test "sarà la combinazione di età, sesso, livello di colesterolo del soggetto e durata dei sintomi:
library( rms )
library( lattice )
library( latticeExtra )
library( data.table )
getHdata( "acath" )
acath <- acath[ !is.na( acath$choleste ), ]
dd <- datadist( acath )
options( datadist = "dd" )
fit <- lrm( sigdz ~ rcs( age )*sex + rcs( choleste ) + cad.dur, data = acath )
Vale la pena tracciare i rischi previsti su scala logit, per vedere come sono normali (essenzialmente, questo era quello che abbiamo assunto in precedenza, con un singolo test!):
densityplot( ~predict( fit ), groups = acath$sigdz, plot.points = FALSE, ref = TRUE,
auto.key = list( columns = 2 ) )
Beh, sono quasi normali ...
Andiamo avanti e calcoliamo il costo complessivo previsto:
ExpectedOverallCost <- function( b, p, y, cplusminus, cminusplus,
cplusplus = 1, cminusminus = 0 ) {
sum( table( factor( p>b, levels = c( FALSE, TRUE ) ), y )*matrix(
c( cminusminus, cplusminus, cminusplus, cplusplus ), nc = 2 ) )
}
table( predict( fit, type = "fitted" )>0.5, acath$sigdz )
ExpectedOverallCost( 0.5, predict( fit, type = "fitted" ), acath$sigdz, 2, 4 )
E tracciamolo per tutti i costi possibili (una nota computazionale: non abbiamo bisogno di iterare inconsapevolmente attraverso i numeri da 0 a 1, possiamo ricostruire perfettamente la curva calcolandola per tutti i valori unici delle probabilità previste):
ps <- sort( unique( c( 0, 1, predict( fit, type = "fitted" ) ) ) )
xyplot( sapply( ps, ExpectedOverallCost,
p = predict( fit, type = "fitted" ), y = acath$sigdz,
cplusminus = 2, cminusplus = 4 ) ~ ps, type = "l", xlab = "Threshold",
ylab = "Expected overall cost", panel = function( x, y, ... ) {
panel.xyplot( x, y, ... )
panel.points( x[ which.min( y ) ], min( y ), pch = 19, cex = 1.1 )
panel.text( x[ which.min( y ) ], min( y ), round( x[ which.min( y ) ], 3 ),
pos = 3 )
} )
Possiamo benissimo vedere dove dovremmo mettere la soglia per ottimizzare il costo complessivo previsto (senza usare sensibilità, specificità o valori predittivi ovunque!). Questo è l'approccio corretto.
È particolarmente istruttivo contrastare queste metriche:
ExpectedOverallCost2 <- function( b, p, y, cplusminus, cminusplus,
cplusplus = 1, cminusminus = 0 ) {
tab <- table( factor( p>b, levels = c( FALSE, TRUE ) ), y )
sens <- tab[ 2, 2 ] / sum( tab[ , 2 ] )
spec <- tab[ 1, 1 ] / sum( tab[ , 1 ] )
c( `Expected overall cost` = sum( tab*matrix( c( cminusminus, cplusminus, cminusplus,
cplusplus ), nc = 2 ) ),
Sensitivity = sens,
Specificity = spec,
PPV = tab[ 2, 2 ] / sum( tab[ 2, ] ),
NPV = tab[ 1, 1 ] / sum( tab[ 1, ] ),
Accuracy = 1 - ( tab[ 1, 1 ] + tab[ 2, 2 ] )/sum( tab ),
Youden = 1 - ( sens + spec - 1 ),
Topleft = ( 1-sens )^2 + ( 1-spec )^2
)
}
ExpectedOverallCost2( 0.5, predict( fit, type = "fitted" ), acath$sigdz, 2, 4 )
res <- melt( data.table( ps, t( sapply( ps, ExpectedOverallCost2,
p = predict( fit, type = "fitted" ),
y = acath$sigdz,
cplusminus = 2, cminusplus = 4 ) ) ),
id.vars = "ps" )
p1 <- xyplot( value ~ ps, data = res, subset = variable=="Expected overall cost",
type = "l", xlab = "Threshold", ylab = "Expected overall cost",
panel=function( x, y, ... ) {
panel.xyplot( x, y, ... )
panel.abline( v = x[ which.min( y ) ],
col = trellis.par.get()$plot.line$col )
panel.points( x[ which.min( y ) ], min( y ), pch = 19 )
} )
p2 <- xyplot( value ~ ps, groups = variable,
data = droplevels( res[ variable%in%c( "Expected overall cost",
"Sensitivity",
"Specificity", "PPV", "NPV" ) ] ),
subset = variable%in%c( "Sensitivity", "Specificity", "PPV", "NPV" ),
type = "l", xlab = "Threshold", ylab = "Sensitivity/Specificity/PPV/NPV",
auto.key = list( columns = 3, points = FALSE, lines = TRUE ) )
doubleYScale( p1, p2, use.style = FALSE, add.ylab2 = TRUE )
Ora possiamo analizzare quelle metriche che a volte sono specificamente pubblicizzate come in grado di trovare un taglio ottimale senza costi e contrastarlo con il nostro approccio basato sui costi! Usiamo le tre metriche più utilizzate:
- Precisione (massimizza la precisione)
- Regola Youden (massimizza )Sens+Spec−1
- Regola di Topleft (minimizza )(1−Sens)2+(1−Spec)2
(Per semplicità, sottrarremo i valori precedenti da 1 per la regola Youden e Precisione in modo da avere un problema di minimizzazione ovunque.)
Vediamo i risultati:
p3 <- xyplot( value ~ ps, groups = variable,
data = droplevels( res[ variable%in%c( "Expected overall cost", "Accuracy",
"Youden", "Topleft" ) ] ),
subset = variable%in%c( "Accuracy", "Youden", "Topleft" ),
type = "l", xlab = "Threshold", ylab = "Accuracy/Youden/Topleft",
auto.key = list( columns = 3, points = FALSE, lines = TRUE ),
panel = panel.superpose, panel.groups = function( x, y, col.line, ... ) {
panel.xyplot( x, y, col.line = col.line, ... )
panel.abline( v = x[ which.min( y ) ], col = col.line )
panel.points( x[ which.min( y ) ], min( y ), pch = 19, col = col.line )
} )
doubleYScale( p1, p3, use.style = FALSE, add.ylab2 = TRUE )
Questo ovviamente riguarda una specifica struttura dei costi, , , , (questo ovviamente conta solo per la decisione di costo ottimale). Per studiare l'effetto della struttura dei costi, scegliamo solo la soglia ottimale (invece di tracciare l'intera curva), ma la tracciamo in funzione dei costi. Più specificamente, come abbiamo già visto, la soglia ottimale dipende dai quattro costi solo attraverso il rapporto , quindi tracciamo il taglio ottimale in funzione di questo, insieme al tipico utilizzo metriche che non utilizzano i costi:c−−=0c++=1c−+=2c+−=4c−d/c+d
res2 <- data.frame( rat = 10^( seq( log10( 0.02 ), log10( 50 ), length.out = 500 ) ) )
res2$OptThreshold <- sapply( res2$rat,
function( rat ) ps[ which.min(
sapply( ps, Vectorize( ExpectedOverallCost, "b" ),
p = predict( fit, type = "fitted" ),
y = acath$sigdz,
cplusminus = rat,
cminusplus = 1,
cplusplus = 0 ) ) ] )
xyplot( OptThreshold ~ rat, data = res2, type = "l", ylim = c( -0.1, 1.1 ),
xlab = expression( {"c"^{"-"}}["d"]/{"c"^{"+"}}["d"] ), ylab = "Optimal threshold",
scales = list( x = list( log = 10, at = c( 0.02, 0.05, 0.1, 0.2, 0.5, 1,
2, 5, 10, 20, 50 ) ) ),
panel = function( x, y, resin = res[ ,.( ps[ which.min( value ) ] ),
.( variable ) ], ... ) {
panel.xyplot( x, y, ... )
panel.abline( h = resin[variable=="Youden"] )
panel.text( log10( 0.02 ), resin[variable=="Youden"], "Y", pos = 3 )
panel.abline( h = resin[variable=="Accuracy"] )
panel.text( log10( 0.02 ), resin[variable=="Accuracy"], "A", pos = 3 )
panel.abline( h = resin[variable=="Topleft"] )
panel.text( log10( 0.02 ), resin[variable=="Topleft"], "TL", pos = 1 )
} )
Le linee orizzontali indicano gli approcci che non utilizzano i costi (e sono quindi costanti).
Ancora una volta, vediamo bene che quando il costo aggiuntivo di errata classificazione nel gruppo sano aumenta rispetto a quello del gruppo malato, la soglia ottimale aumenta: se davvero non vogliamo che le persone sane siano classificate come malate, useremo un cutoff più elevato (e viceversa, ovviamente!).
E, infine, vediamo ancora una volta perché questi metodi che non utilizzano i costi non sono ( e non possono! ) Essere sempre ottimali.