Dette skriptet følger kodebitene i forelesning 2. Linjer som starter med #' blir tekst i den rendrede HTML-filen. Linjer som starter med # er kommentarer inne i koden. Tallene i forklaringene gjelder når du kjører skriptet med frøene (set.seed()) som står her.

1. Pakke og simulerte ringdata

Som i forelesning 1 bruker vi pakken e1071.

if (!requireNamespace("e1071", quietly = TRUE)) {
  stop("Installer pakken med install.packages('e1071') før du kjører skriptet.")
}
library(e1071)

Vi lager 240 punkter: 120 i et sentrum og 120 i en ring rundt. Punktene lages i polarkoordinater: en vinkel (retning fra origo) og en radius (avstand fra origo). Deretter regner vi om til vanlige koordinater med \(x_1 = r\cos\theta\) og \(x_2 = r\sin\theta\). Slik er det lett å styre hvor langt fra sentrum hver klasse ligger.

set.seed(93)
n2 <- 240

# Klassene: de første 120 er «Sentrum», de neste 120 er «Ring»
klasse <- rep(c("Sentrum", "Ring"), each = n2 / 2)

# Vinkel: tilfeldig retning rundt hele sirkelen (0 til 2*pi radianer)
vinkel <- runif(n2, 0, 2 * pi)

# Radius: sentrum ligger 0–1,2 fra origo, ringen 0,9–2,1.
# Intervallene overlapper mellom 0,9 og 1,2, så klassene kan ikke skilles perfekt
radius <- c(runif(n2 / 2, 0, 1.2), runif(n2 / 2, 0.9, 2.1))

dat_ring <- data.frame(
  # Fra polarkoordinater til x1 og x2, pluss litt støy (sd = 0,2)
  x1 = radius * cos(vinkel) + rnorm(n2, sd = 0.2),
  x2 = radius * sin(vinkel) + rnorm(n2, sd = 0.2),
  # levels = ... bestemmer rekkefølgen på klassene: «Sentrum» blir første nivå, «Ring» andre
  y  = factor(klasse, levels = c("Sentrum", "Ring"))
)

plot(dat_ring$x1, dat_ring$x2,
     col = ifelse(dat_ring$y == "Ring", "#cc583b", "#276ca6"),
     pch = 19, xlab = "x1", ylab = "x2", asp = 1)   # asp = 1: lik skala, så sirkelen ser rund ut
legend("topright", legend = levels(dat_ring$y),
       col = c("#276ca6", "#cc583b"), pch = 19, bty = "n")

Slik leser du figuren: De blå punktene (sentrum) ligger samlet rundt origo, og de røde (ringen) ligger rundt dem. Ingen rett linje kan skille de to klassene: uansett hvor vi legger linjen, får vi både blå og røde punkter på hver side. I overgangssonen blander klassene seg, så heller ikke en buet grense kan klassifisere alle punktene riktig. # 2. Trening og test, og tuning med kryssvalidering

Vi deler data i 70 % trening og 30 % test. Delingen er stratifisert: vi trekker 70 % fra hver klasse for seg, slik at begge delene får like mange av hver klasse.

set.seed(94)
id_ring <- unlist(lapply(
  split(seq_len(nrow(dat_ring)), dat_ring$y),          # Radnumrene delt i én liste per klasse
  function(ii) sample(ii, size = round(0.7 * length(ii)))  # Trekk 70 % av radene i hver klasse
))                                                     # unlist() slår listene sammen igjen
train_ring <- dat_ring[id_ring, ]    # 168 observasjoner: 84 Sentrum og 84 Ring
test_ring  <- dat_ring[-id_ring, ]   # 72 observasjoner: 36 Sentrum og 36 Ring

Vi tuner to modeller med femfolds kryssvalidering (CV) på treningsdataene:

Begge bruker samme frø, slik at foldene blir like og sammenligningen rettferdig. svm() skalerer prediktorene automatisk (scale = TRUE er standard), og det skjer på nytt inne i hver fold.

set.seed(95)
cv_rett <- tune(
  svm, y ~ ., data = train_ring, kernel = "linear",
  ranges = list(cost = c(0.1, 1, 10)),
  tunecontrol = tune.control(cross = 5)
)
set.seed(95)
cv_radial <- tune(
  svm, y ~ ., data = train_ring, kernel = "radial",
  ranges = list(cost  = c(0.01, 0.1, 1, 10, 100),   # Hvor dyrt det er å bryte marginen
                gamma = c(0.05, 0.2, 1, 5)),        # Hvor raskt likheten avtar med avstanden
  tunecontrol = tune.control(cross = 5)
)

cv_rett$best.parameters
##   cost
## 2    1
cv_radial$best.parameters
##    cost gamma
## 10  100   0.2

Slik leser du output: For den lineære modellen velges cost = 1, og for den radiale velges cost = 100 og gamma = 0,2. Tallene til venstre (2 og 10) er bare radnumre i rutenettet av kombinasjoner.

# summary() gir CV-feil for alle 20 kombinasjoner. Vi sorterer etter feil og viser de seks beste
perf <- summary(cv_radial)$performances
head(perf[order(perf$error), ], 6)
##     cost gamma     error dispersion
## 10 100.0   0.2 0.1611408 0.07034621
## 7    0.1   0.2 0.1672014 0.09248271
## 18   1.0   5.0 0.1787879 0.06445397
## 8    1.0   0.2 0.1789661 0.09628845
## 9   10.0   0.2 0.1848485 0.05099566
## 13   1.0   1.0 0.1909091 0.07645791

Slik leser du output: error er gjennomsnittlig andel feilklassifiserte observasjoner i valideringsfoldene (CV-feil). dispersion er standardavviket til feilraten over de fem foldene, et mål på usikkerheten.

De seks beste kombinasjonene har CV-feil mellom 0,161 og 0,191. Forskjellene er små sammenlignet med dispersion (0,05–0,10). cost varierer fra 0,1 til 100 blant de beste, og gamma = 0,2 går igjen. Vi lærer altså mer av området som fungerer (lav gamma) enn av akkurat den kombinasjonen som vant. # 3. Tegne beslutningsgrensen

En radial SVM gir en buet grense, og den kan vi ikke tegne med abline() som i forelesning 1. I stedet regner vi ut skåren \(f(x)\) i et tett rutenett av punkter og tegner nivåkurver med contour():

Dette er samme idé som høydekoter på et kart.

tegn_grense <- function(mod, data, main = "") {
  # 150 x 150 = 22 500 punkter som dekker området der dataene ligger
  x1 <- seq(min(data$x1), max(data$x1), length.out = 150)
  x2 <- seq(min(data$x2), max(data$x2), length.out = 150)
  rutenett <- expand.grid(x1 = x1, x2 = x2)

  # Beslutningsverdien f(x) i hvert rutenettpunkt
  f <- attr(predict(mod, rutenett, decision.values = TRUE), "decision.values")[, 1]

  plot(data$x1, data$x2, col = ifelse(data$y == "Ring", "#cc583b", "#276ca6"),
       pch = 19, asp = 1, xlab = "x1", ylab = "x2", main = main)

  # matrix(f, 150) legger verdiene tilbake i rutenettets form
  contour(x1, x2, matrix(f, 150), levels = 0, add = TRUE, lwd = 2,
          drawlabels = FALSE)                                    # Beslutningsgrensen
  contour(x1, x2, matrix(f, 150), levels = c(-1, 1), add = TRUE, lty = 2,
          drawlabels = FALSE)                                    # Marginene

  # Ring rundt støttevektorene
  points(data[mod$index, c("x1", "x2")], cex = 1.6)
}
par(mfrow = c(1, 2))
tegn_grense(cv_rett$best.model, train_ring, main = "Lineær SVC")
tegn_grense(cv_radial$best.model, train_ring, main = "Radial SVM (CV-valgt)")

par(mfrow = c(1, 1))

Slik leser du figuren: Den lineære SVC-en (venstre) legger en rett linje gjennom skyen. Marginene er svært brede, og nesten alle punktene har ring: de er støttevektorer. Det er et tegn på at en rett linje ikke klarer å skille klassene.

Den radiale SVM-en (høyre) har en lukket, omtrent sirkelformet grense rundt sentrum, som svarer godt til hvordan vi simulerte dataene. Støttevektorene ligger i overgangssonen mellom klassene. Punkter langt inne i sentrum eller langt ute i ringen har ingen ring og påvirker ikke grensen. # 4. Overtilpasning: hva skjer med gamma = 50?

Vi tilpasser en bevisst overtilpasset modell med svært høy gamma og cost. Høy gamma gjør at hvert punkt bare påvirker et lite område rundt seg, og høy cost gjør brudd svært dyre.

mod_overtilp <- svm(y ~ ., data = train_ring, kernel = "radial",
                    cost = 100, gamma = 50)

# Vi samler de tre modellene i en navngitt liste, så vi kan behandle dem likt med sapply()
modeller <- list("Lineær SVC"            = cv_rett$best.model,
                 "Radial SVM (CV-valgt)" = cv_radial$best.model,
                 "Radial SVM (gamma 50)" = mod_overtilp)

# Andel riktige klassifikasjoner: == gir TRUE ved riktig, og mean() av TRUE/FALSE er andelen
treff <- function(mod, data) mean(predict(mod, data) == data$y)

data.frame(
  modell              = names(modeller),
  stottevektorer      = sapply(modeller, function(m) m$tot.nSV),
  treningsnoyaktighet = round(sapply(modeller, treff, data = train_ring), 3),
  testnoyaktighet     = round(sapply(modeller, treff, data = test_ring), 3),
  row.names = NULL
)
##                  modell stottevektorer treningsnoyaktighet testnoyaktighet
## 1            Lineær SVC            158               0.637           0.639
## 2 Radial SVM (CV-valgt)             61               0.857           0.806
## 3 Radial SVM (gamma 50)            159               1.000           0.694

Slik leser du output:

Legg merke til at både den lineære modellen og den overtilpassede bruker nesten alle punktene som støttevektorer, men av motsatte grunner. Den lineære er for stiv og trenger svært brede marginer. Den overtilpassede er så fleksibel at nesten hvert punkt får sin egen lille «øy».

tegn_grense(mod_overtilp, train_ring, main = "Radial SVM (gamma 50)")

Slik leser du figuren: Grensen består av mange små lukkede kurver rundt enkeltpunkter. Et nytt punkt som havner mellom øyene, klassifiseres nesten tilfeldig. Vi ser også på forvekslingsmatrisen for den CV-valgte radiale modellen på testdata, med «Ring» som positiv klasse.

table(faktisk   = test_ring$y,
      predikert = predict(cv_radial$best.model, test_ring))
##          predikert
## faktisk   Sentrum Ring
##   Sentrum      30    6
##   Ring          8   28

Slik leser du output: Av de 36 ringpunktene ble 28 riktig klassifisert, så sensitiviteten er \(28/36 \approx 0{,}78\). Av de 36 sentrumspunktene ble 30 riktig klassifisert, så spesifisiteten er \(30/36 \approx 0{,}83\). Til sammen \(58/72 \approx 0{,}81\) riktige, som i tabellen over. # 5. Kjernetrikset regnet ut for hånd

En SVM med kjerne predikerer med \[f(x) = \beta_0 + \sum_{i \in \mathcal S} \alpha_i K(x, x_i).\] Vi regner ut dette selv for testdataene og sjekker at vi får samme svar som predict(). Det viser at det ikke skjer noe mer enn det formelen sier.

Det vi trenger, ligger lagret i modellobjektet:

mod <- cv_radial$best.model

# Steg 1: Skaler testdata med treningsdataenes gjennomsnitt og standardavvik,
# akkurat slik svm() gjør internt. Ellers sammenligner vi punkter på ulik skala
x_ny <- scale(as.matrix(test_ring[, c("x1", "x2")]),
              center = mod$x.scale$`scaled:center`,
              scale  = mod$x.scale$`scaled:scale`)

# Steg 2: Kvadrert avstand mellom hvert testpunkt og hver støttevektor.
# Vi bruker identiteten ||a - b||^2 = ||a||^2 + ||b||^2 - 2 a'b, som regner ut alle par på én gang.
# Resultatet er en 72 x 61-matrise: én rad per testpunkt, én kolonne per støttevektor
avstand2 <- outer(rowSums(x_ny^2), rowSums(mod$SV^2), "+") - 2 * x_ny %*% t(mod$SV)

# Steg 3: Radialkjernen K = exp(-gamma * avstand^2). Verdier nær 1 = like punkter, nær 0 = ulike
K <- exp(-mod$gamma * avstand2)

# Steg 4: Vektet sum over støttevektorene pluss konstantledd: f(x) = sum_i alpha_i K(x, x_i) + beta_0
f_for_haand <- drop(K %*% mod$coefs) - mod$rho

# Til sammenligning: beslutningsverdiene fra predict()
f_e1071 <- attr(predict(mod, test_ring, decision.values = TRUE), "decision.values")

c(antall_stottevektorer = nrow(mod$SV),
  storste_avvik = max(abs(f_for_haand - f_e1071[, 1])))
## antall_stottevektorer         storste_avvik 
##           6.10000e+01           5.82645e-13

Slik leser du output: Modellen har 61 støttevektorer. Største avvik mellom vår utregning og predict() er \(5{,}8 \times 10^{-13}\), altså 0,00000000000058. Det er bare avrundingsfeil i datamaskinen: de to utregningene er i praksis identiske.

Prediksjonen for et nytt punkt er en vektet sum av hvor likt punktet er hver av de 61 støttevektorene. De andre 107 treningsobservasjonene er ikke med i summen i det hele tatt. # 6. ROC-kurver og AUC

Vi skriver tre små hjelpefunksjoner i stedet for å bruke en ferdig pakke, slik at det er tydelig hva som beregnes.

skaar() henter beslutningsverdien \(f(x)\) og sørger for at høy verdi alltid betyr «Ring». Det trengs fordi e1071 setter fortegnet etter hvilken klasse som dukker opp først i treningsdataene, ikke etter rekkefølgen i levels(). Kolonnenavnet, for eksempel "Sentrum/Ring", forteller hvilken klasse som har positivt fortegn: den første.

skaar <- function(mod, data, positiv = "Ring") {
  f <- attr(predict(mod, data, decision.values = TRUE), "decision.values")
  forste_klasse <- strsplit(colnames(f), "/")[[1]][1]   # Klassen før «/» har positivt fortegn
  if (forste_klasse == positiv) f[, 1] else -f[, 1]     # Snu fortegnet ved behov
}

roc_punkter() lager punktene på ROC-kurven. Vi bruker hver observerte skår som terskel \(c\) og sier «Ring» når skåren er minst \(c\). For hver terskel regner vi ut

roc_punkter <- function(s, y, positiv = "Ring") {
  # Tersklene fra høyest til lavest. Inf gir startpunktet (0, 0) der ingen kalles positiv
  terskler <- sort(unique(c(Inf, s)), decreasing = TRUE)
  data.frame(
    fpr = sapply(terskler, function(t) mean(s[y != positiv] >= t)),  # Blant de negative
    tpr = sapply(terskler, function(t) mean(s[y == positiv] >= t))   # Blant de positive
  )
}

auc() bruker tolkningen av AUC direkte: sannsynligheten for at et tilfeldig positivt punkt får høyere skår enn et tilfeldig negativt. Vi sammenligner alle par av ett positivt og ett negativt punkt (på testdata \(36 \times 36 = 1296\) par) og teller andelen der det positive punktet har høyest skår. Like skårer teller som et halvt.

auc <- function(s, y, positiv = "Ring") {
  s_pos <- s[y == positiv]                       # Skårer for de positive
  s_neg <- s[y != positiv]                       # Skårer for de negative
  # outer() lager en tabell med alle par. ">" gir 1 der positiv skår er høyest
  mean(outer(s_pos, s_neg, ">") + 0.5 * outer(s_pos, s_neg, "=="))
}

Nå tegner vi ROC-kurvene for alle tre modellene, på trening og test.

farger <- c("grey40", "#276ca6", "#cc583b")
par(mfrow = c(1, 2))
for (del in c("Treningsdata", "Testdata")) {
  d <- if (del == "Treningsdata") train_ring else test_ring
  # Tomt plott med aksene fra 0 til 1
  plot(NULL, xlim = c(0, 1), ylim = c(0, 1), asp = 1, main = del,
       xlab = "Andel falske positive (FPR)", ylab = "Sensitivitet (TPR)")
  abline(0, 1, lty = 3)   # Diagonalen: tilfeldig gjetting
  for (k in seq_along(modeller)) {
    lines(roc_punkter(skaar(modeller[[k]], d), d$y), col = farger[k], lwd = 2)
  }
  legend("bottomright", legend = names(modeller), col = farger,
         lwd = 2, bty = "n", cex = 0.8)
}

par(mfrow = c(1, 1))

Slik leser du figuren: En god modell har en kurve som går raskt opp mot øvre venstre hjørne: mange ringpunkter oppdages før mange sentrumspunkter blir feilaktig flagget. Den stiplede diagonalen er tilfeldig gjetting.

På treningsdata (venstre) ser modellen med gamma = 50 (rød) perfekt ut: kurven går rett opp til hjørnet. På testdata (høyre) faller den kraftig: kurven ligger lavt, og i starten til og med under diagonalen. Den CV-valgte radiale modellen (blå) holder seg godt på begge. Den lineære modellen (grå) ligger nær diagonalen på begge, fordi en rett linje ikke kan rangere punkter etter avstand fra sentrum.

data.frame(
  modell      = names(modeller),
  AUC_trening = round(sapply(modeller, function(m) auc(skaar(m, train_ring), train_ring$y)), 3),
  AUC_test    = round(sapply(modeller, function(m) auc(skaar(m, test_ring), test_ring$y)), 3),
  row.names = NULL
)
##                  modell AUC_trening AUC_test
## 1            Lineær SVC       0.565    0.537
## 2 Radial SVM (CV-valgt)       0.947    0.910
## 3 Radial SVM (gamma 50)       1.000    0.674

Slik leser du output:

Rangeringen på treningsdata er altså et svært dårlig mål på hvordan modellen fungerer på nye data, nøyaktig som i hjertedataene i video 9.4. # 7. Hengseltap og logistisk tap

Begge metodene kan skrives som «tap + straff». Vi plotter tapet for én observasjon som funksjon av \(y_i f(x_i)\): positive verdier betyr riktig side av grensen, og jo større, desto lenger inn på riktig side.

z <- seq(-4, 3, length.out = 400)                     # Verdier av y*f(x)
plot(z, pmax(0, 1 - z), type = "l", lwd = 2, col = "#cc583b",   # Hengseltap: max(0, 1 - z)
     xlab = expression(y[i] * f(x[i])), ylab = "Tap")
lines(z, log(1 + exp(-z)), lwd = 2, col = "#276ca6")  # Logistisk tap: log(1 + e^(-z))
abline(v = 1, lty = 3)                                # Marginen: y*f(x) = 1
legend("topright", legend = c("Hengseltap (SVM)", "Logistisk tap"),
       col = c("#cc583b", "#276ca6"), lwd = 2, bty = "n")

Slik leser du figuren: Til venstre, der observasjonen ligger på feil side, stiger begge tapene omtrent lineært og ligner hverandre. Den viktige forskjellen er til høyre for den stiplede linjen. Hengseltapet er nøyaktig null for alle observasjoner som ligger utenfor marginen (\(y_i f(x_i) \ge 1\)), så de påvirker ikke løsningen. Det logistiske tapet blir mindre og mindre, men aldri null, så alle observasjoner påvirker en logistisk regresjon litt. Knekkpunktet ved 1 er grunnen til at SVM har støttevektorer. # 8. Logistisk regresjon med kvadratledd

Kan en vanlig logit gjøre jobben hvis vi gir den de riktige variablene? Vi legger til \(x_1^2\), \(x_2^2\) og \(x_1x_2\), akkurat som i avsnittet om utvidelse av prediktorene. I() forteller R at ^ og * skal regnes ut som vanlig aritmetikk, ikke tolkes som formelsyntaks. glm() modellerer sannsynligheten for det andre nivået i faktoren, her «Ring».

logit_kv <- glm(y ~ x1 + x2 + I(x1^2) + I(x2^2) + I(x1 * x2),
                data = train_ring, family = binomial)

round(coef(logit_kv), 2)
## (Intercept)          x1          x2     I(x1^2)     I(x2^2)  I(x1 * x2) 
##       -3.55       -0.32        0.34        2.93        3.62        0.37

Slik leser du output: Kvadratleddene \(x_1^2\) (2,93) og \(x_2^2\) (3,62) er store og positive, mens de lineære leddene og samspillsleddet er små. Jo lenger fra origo et punkt ligger, desto høyere er altså sannsynligheten for «Ring». Beslutningsgrensen ligger der logit-indeksen er null, omtrent der \(2{,}93\,x_1^2 + 3{,}62\,x_2^2 \approx 3{,}55\). Det er en ellipse som er nesten en sirkel med radius rundt 1,0–1,1, midt i overlappssonen vi simulerte (0,9–1,2).

# type = "response" gir sannsynligheter P(Ring | x) i stedet for logit-indeksen
p_test <- predict(logit_kv, newdata = test_ring, type = "response")

data.frame(
  modell = c("Logit med kvadratledd", "Radial SVM (CV-valgt)"),
  # Logit: klassifiser som «Ring» når sannsynligheten er over 0,5
  testnoyaktighet = round(c(mean(ifelse(p_test > 0.5, "Ring", "Sentrum") == test_ring$y),
                            treff(cv_radial$best.model, test_ring)), 3),
  # AUC kan regnes med sannsynligheter eller skårer: bare rangeringen teller
  AUC_test = round(c(auc(p_test, test_ring$y),
                     auc(skaar(cv_radial$best.model, test_ring), test_ring$y)), 3)
)
##                  modell testnoyaktighet AUC_test
## 1 Logit med kvadratledd           0.847    0.932
## 2 Radial SVM (CV-valgt)           0.806    0.910

Slik leser du output: Logit med kvadratledd gjør det minst like godt som den radiale SVM-en: 85 % mot 81 % riktig, og AUC 0,93 mot 0,91. Med bare 72 testobservasjoner er forskjellen liten (tre observasjoner i nøyaktighet), så vi bør ikke kåre en vinner.

Men legg merke til hvorfor logit gjør det så godt: vi visste at dataene lå i en ring, fordi vi simulerte dem selv, og kunne derfor velge akkurat de riktige transformasjonene. Med virkelige data og mange prediktorer vet vi sjelden det. Da er kjernen nyttig: den finner en fleksibel grense uten at vi må gjette transformasjonene. Til gjengjeld gir logit oss sannsynligheter og koeffisienter som kan tolkes, og det gjør ikke SVM-en.