EU:n lainsäädäntö jakaumana — Osa 5: Eloonjäämisanalyysi

Kuinka kauan EU-säädös elää? Kaplan-Meier ja Cox-regressio paljastavat, mitä ikäjakauma piilottaa.

Tekijä

Kristian Vepsäläinen

Julkaistu

18.6.2026

Koodi
library(tidyverse)
library(here)
library(lubridate)
library(scales)
library(ggtext)
library(patchwork)
library(survival)    # Kaplan-Meier ja Cox
library(survminer)   # ggplot2-pohjainen survival-visualisointi

col_red    <- "#e63946"
col_green  <- "#2a9d8f"
col_orange <- "#f4a261"
col_navy   <- "#1d3557"
col_blue   <- "#457b9d"

theme_set(
  theme_minimal(base_size = 14) +
    theme(
      plot.title       = element_markdown(face = "bold", size = 15),
      plot.subtitle    = element_markdown(color = "grey40"),
      plot.caption     = element_text(color = "grey55", size = 9),
      axis.title       = element_text(color = "grey30"),
      panel.grid.minor = element_blank()
    )
)

Säädöksen elinikä on stokastinen prosessi

Osassa 1 havaitsimme selviytymisharhan: vanhoja säädöksiä on jäljellä vain kaikkein sitkeimmät. Yksinkertainen ikäjakauma on siksi harhainen estimaatti säädösten todellisesta elinajasta.

Eloonjäämisanalyysi (survival analysis) on kehitetty juuri tätä ongelmaa varten. Se ottaa eksplisiittisesti huomioon sensuroinnin: voimassaolevat säädökset ovat “elossa” tähän päivään, mutta emme tiedä milloin ne kumotaan. Ne eivät ole puuttuvia havaintoja — ne ovat informatiivisesti sensuroituja.

Kaksi keskeistä työkalua:

Kaplan-Meier-estimaatti laskee eloonjäämisfunktion \(S(t)\) — todennäköisyyden, että säädös on yhä voimassa ajankohdassa \(t\) — ottaen sensuroinnin huomioon. Se on ei-parametrinen: ei oletuksia jakauman muodosta.

Cox-regressio (proportional hazards) mallintaa, mitkä tekijät ennustavat kumoamisriskiä. Tuloksena on hasardisuhde (HR): HR > 1 tarkoittaa suurempaa kumoamisriskiä, HR < 1 tarkoittaa pienempää. Cox ei oleta jakaumaa — se olettaa vain, että kovariaaттien vaikutus on suhteellinen ajan suhteen.


Data ja muuttujat

Koodi
data_path <- here("data/eu/eu_saadanto_raw.rds")

if (!file.exists(data_path)) {
  stop(
    "Tiedostoa ", data_path, " ei löydy.\n",
    "Aja ensin osa 1 lokaalisti tallentaaksesi datan."
  )
}

raw <- readRDS(data_path)
message("Data ladattu: ", nrow(raw), " riviä")
Koodi
# Eloonjäämisanalyysin muuttujat:
#
# aika    = säädöksen ikä päivinä hyväksymisestä joko kumoamiseen tai
#           tähän päivään (kumpi tulee ensin)
# tapaus  = 1 jos kumottu (tapahtuma havaittu), 0 jos vielä voimassa (sensuroitu)
#
# Kovariaaтit Cox-regressioon:
#   saadostyyppi  = regulation / directive / decision / recommendation
#   vuosikymmen   = hyväksymisvuosikymmen (institutionaalinen konteksti)
#   kohortti_eu15 = hyväksytty ennen 2004-laajentumista vai sen jälkeen

tanaan <- Sys.Date()

surv_df <- raw |>
  filter(!is.na(date), !is.na(force)) |>
  mutate(
    date         = as.Date(date),
    vuosi        = year(date),
    saadostyyppi = case_when(
      resource_type == "regulation"     ~ "Asetus",
      resource_type == "directive"      ~ "Direktiivi",
      resource_type == "decision"       ~ "Päätös",
      resource_type == "recommendation" ~ "Suositus"
    ),
    # Tapaus: 1 = kumottu, 0 = vielä voimassa (sensuroitu)
    tapaus = if_else(force == "false", 1L, 0L),
    # Aika päivinä hyväksymisestä tähän päivään tai kumoamiseen
    # Koska kumoamispäivää ei ole suoraan datassa, käytetään
    # nykypäivää sensurointipisteenä kaikille
    aika_pv = as.numeric(tanaan - date),
    aika_v  = aika_pv / 365.25,
    # Kovariaaтit
    vuosikymmen = factor(
      paste0(floor(vuosi / 10) * 10, "-luku"),
      levels = paste0(seq(1950, 2020, 10), "-luku")
    ),
    kohortti = factor(
      if_else(vuosi < 2004, "Ennen 2004-laajentumista", "2004-laajentumisen jälkeen")
    )
  ) |>
  filter(
    vuosi >= 1960,
    vuosi <= 2022,      # 2023 säädökset liian nuoria merkitykselliseen analyysiin
    aika_pv > 0,
    !is.na(saadostyyppi)
  )

cat("Havaintoja analyysissa:", nrow(surv_df), "\n")
Havaintoja analyysissa: 110310 
Koodi
cat("\nTapaukset (kumottu vs. voimassa):\n")

Tapaukset (kumottu vs. voimassa):
Koodi
count(surv_df, tapaus) |>
  mutate(selite = if_else(tapaus == 1, "Kumottu (tapahtuma)", "Voimassa (sensuroitu)"),
         osuus  = percent(n / sum(n), 0.1)) |>
  select(selite, n, osuus) |>
  print()
                 selite     n osuus
1 Voimassa (sensuroitu) 37332 33.8%
2   Kumottu (tapahtuma) 72978 66.2%
Koodi
cat("\nSäädöstyypit:\n")

Säädöstyypit:
Koodi
count(surv_df, saadostyyppi, tapaus) |>
  pivot_wider(names_from = tapaus, values_from = n,
              names_prefix = "tapaus_") |>
  mutate(
    yht          = tapaus_0 + tapaus_1,
    pct_kumottu  = percent(tapaus_1 / yht, 0.1)
  ) |>
  select(saadostyyppi, yht, kumottu = tapaus_1, pct_kumottu) |>
  arrange(desc(yht)) |>
  print()
# A tibble: 4 × 4
  saadostyyppi   yht kumottu pct_kumottu
  <chr>        <int>   <int> <chr>      
1 Asetus       58206   45876 78.8%      
2 Päätös       46774   23559 50.4%      
3 Direktiivi    4273    3084 72.2%      
4 Suositus      1057     459 43.4%      

Kaplan-Meier: eloonjäämisfunktio tyypeittäin

Koodi
# Survival-objekti
surv_obj <- Surv(time = surv_df$aika_v, event = surv_df$tapaus)

# KM-estimaatti tyypeittäin
km_tyypit <- survfit(surv_obj ~ saadostyyppi, data = surv_df)

# Mediaanit tyypeittäin
cat("Mediaani elinikä (vuosia) tyypeittäin:\n")
Mediaani elinikä (vuosia) tyypeittäin:
Koodi
print(km_tyypit, print.rmean = TRUE)
Call: survfit(formula = surv_obj ~ saadostyyppi, data = surv_df)

                            n events rmean* se(rmean) median 0.95LCL 0.95UCL
saadostyyppi=Asetus     58206  45876   37.1    0.0567   38.2    38.1    38.4
saadostyyppi=Direktiivi  4273   3084   35.7    0.2169   35.4    34.9    35.9
saadostyyppi=Päätös     46774  23559   37.4    0.0885   38.5    38.2    38.7
saadostyyppi=Suositus    1057    459   46.2    0.5778   48.1    47.4    48.4
    * restricted mean with upper limit =  66.4 
Koodi
# Värikartta survminer-funktiolle
km_varit <- c(col_red, col_green, col_blue, col_orange)
names(km_varit) <- paste0("saadostyyppi=",
                           c("Asetus", "Direktiivi", "Päätös", "Suositus"))

km_plot <- ggsurvplot(
  km_tyypit,
  data          = surv_df,
  palette       = c(col_red, col_green, col_blue, col_orange),
  conf.int      = TRUE,
  conf.int.alpha = 0.12,
  censor        = FALSE,
  xlab          = "Ikä (vuosia)",
  ylab          = "S(t) — todennäköisyys olla voimassa",
  legend.title  = "",
  legend.labs   = c("Asetus", "Direktiivi", "Päätös", "Suositus"),
  ggtheme       = theme_minimal(base_size = 13),
  risk.table    = TRUE,
  risk.table.height = 0.28,
  risk.table.title  = "Vaarassa oleva joukko (n)",
  xlim          = c(0, 50),
  break.time.by = 5,
  title         = "Kaplan-Meier: säädösten eloonjääminen tyypeittäin"
)

# Lisätään caption manuaalisesti
km_plot$plot <- km_plot$plot +
  labs(
    caption = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com"
  ) +
  theme(plot.title = element_markdown(face = "bold", size = 15))

print(km_plot)

Kaplan-Meier-eloonjäämiskäyrät säädöstyypeittäin. S(t) = todennäköisyys olla voimassa ajankohdassa t. Pystyviivat = sensurointipisteet.
Koodi
# Log-rank-testi: onko tyypeillä tilastollisesti merkitsevä ero elinajassa?
lr_testi <- survdiff(surv_obj ~ saadostyyppi, data = surv_df)
cat("Log-rank-testi (H0: eloonjäämiskäyrät identtiset):\n")
Log-rank-testi (H0: eloonjäämiskäyrät identtiset):
Koodi
print(lr_testi)
Call:
survdiff(formula = surv_obj ~ saadostyyppi, data = surv_df)

                            N Observed Expected (O-E)^2/E (O-E)^2/V
saadostyyppi=Asetus     58206    45876    43322     150.6     382.2
saadostyyppi=Direktiivi  4273     3084     2736      44.4      46.2
saadostyyppi=Päätös     46774    23559    25937     218.1     348.2
saadostyyppi=Suositus    1057      459      983     279.6     285.3

 Chisq= 714  on 3 degrees of freedom, p= <2e-16 
Koodi
cat("\np-arvo:", round(1 - pchisq(lr_testi$chisq, df = length(lr_testi$n) - 1), 6), "\n")

p-arvo: 0 

Kaplan-Meier: vuosikymmenten välinen vertailu

Ovatko vanhemmat direktiivit sitkeämpiä kuin uudemmat — vai onko trendi päinvastainen?

Koodi
km_vk <- survfit(surv_obj ~ vuosikymmen, data = surv_df)

km_vk_plot <- ggsurvplot(
  km_vk,
  data           = surv_df,
  palette        = c(col_navy, col_blue, col_green, col_orange,
                     col_red, "#9b2226", "#ae2012"),
  conf.int       = FALSE,
  censor         = FALSE,
  xlab           = "Ikä (vuosia)",
  ylab           = "S(t) — todennäköisyys olla voimassa",
  legend.title   = "Hyväksymisvuosikymmen",
  ggtheme        = theme_minimal(base_size = 13),
  xlim           = c(0, 55),
  break.time.by  = 5,
  title          = "**Eloonjääminen hyväksymisvuosikymmenen mukaan**"
)

km_vk_plot$plot <- km_vk_plot$plot +
  labs(caption = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com")

print(km_vk_plot)

KM-eloonjäämiskäyrät hyväksymisvuosikymmenen mukaan. Selviytymisharha on korjattu — näemme todellisen elinaikajakauman.

Cox-regressio: mitkä tekijät ennustavat kumoamisriskiä?

Kaplan-Meier näyttää käyrät — Cox kertoo miksi ne eroavat. Cox-regressio mallintaa hasardia \(h(t)\): hetkellinen kumoamisriski ehdolla että säädös on yhä voimassa ajankohdassa \(t\).

\[h(t | \mathbf{x}) = h_0(t) \cdot \exp(\beta_1 x_1 + \beta_2 x_2 + \cdots)\]

Tulkinta: \(\exp(\beta)\) = hasardisuhde (HR). HR = 1.5 tarkoittaa 50 % suurempaa kumoamisriskiä per yksikkö kovariaaтissa.

Koodi
# Referenssitasot: Asetus, 1960-luku, Ennen 2004-laajentumista
surv_df_cox <- surv_df |>
  mutate(
    saadostyyppi = relevel(factor(saadostyyppi), ref = "Asetus"),
    vuosikymmen  = relevel(vuosikymmen, ref = "1960-luku"),
    kohortti     = relevel(kohortti, ref = "Ennen 2004-laajentumista")
  )

cox_fit <- coxph(
  Surv(aika_v, tapaus) ~ saadostyyppi + vuosikymmen + kohortti,
  data = surv_df_cox
)

cat("Cox-regressiotulokset:\n")
Cox-regressiotulokset:
Koodi
summary(cox_fit)
Call:
coxph(formula = Surv(aika_v, tapaus) ~ saadostyyppi + vuosikymmen + 
    kohortti, data = surv_df_cox)

  n= 110310, number of events= 72978 

                                         coef  exp(coef)   se(coef)       z
saadostyyppiDirektiivi             -3.101e-02  9.695e-01  1.866e-02  -1.662
saadostyyppiPäätös                 -2.690e-01  7.641e-01  8.344e-03 -32.240
saadostyyppiSuositus               -5.946e-01  5.518e-01  4.712e-02 -12.620
vuosikymmen1950-luku                       NA         NA  0.000e+00      NA
vuosikymmen1970-luku                2.113e+01  1.496e+09  2.095e+02   0.101
vuosikymmen1980-luku                4.304e+01  4.942e+18  2.587e+02   0.166
vuosikymmen1990-luku                6.541e+01  2.558e+28  3.079e+02   0.212
vuosikymmen2000-luku                8.789e+01  1.473e+38  3.834e+02   0.229
vuosikymmen2010-luku                1.113e+02  2.238e+48  7.297e+02   0.153
vuosikymmen2020-luku                       NA         NA  0.000e+00      NA
kohortti2004-laajentumisen jälkeen  2.182e+01  3.007e+09  2.731e+02   0.080
                                   Pr(>|z|)    
saadostyyppiDirektiivi               0.0965 .  
saadostyyppiPäätös                   <2e-16 ***
saadostyyppiSuositus                 <2e-16 ***
vuosikymmen1950-luku                     NA    
vuosikymmen1970-luku                 0.9197    
vuosikymmen1980-luku                 0.8679    
vuosikymmen1990-luku                 0.8318    
vuosikymmen2000-luku                 0.8187    
vuosikymmen2010-luku                 0.8787    
vuosikymmen2020-luku                     NA    
kohortti2004-laajentumisen jälkeen   0.9363    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

                                   exp(coef) exp(-coef)  lower .95  upper .95
saadostyyppiDirektiivi             9.695e-01  1.031e+00  9.347e-01  1.006e+00
saadostyyppiPäätös                 7.641e-01  1.309e+00  7.517e-01  7.767e-01
saadostyyppiSuositus               5.518e-01  1.812e+00  5.031e-01  6.051e-01
vuosikymmen1950-luku                      NA         NA         NA         NA
vuosikymmen1970-luku               1.496e+09  6.684e-10 7.087e-170 3.158e+187
vuosikymmen1980-luku               4.942e+18  2.023e-19 2.948e-202 8.287e+238
vuosikymmen1990-luku               2.558e+28  3.909e-29 2.140e-234 3.059e+290
vuosikymmen2000-luku               1.473e+38  6.789e-39 6.398e-289        Inf
vuosikymmen2010-luku               2.238e+48  4.468e-49  0.000e+00        Inf
vuosikymmen2020-luku                      NA         NA         NA         NA
kohortti2004-laajentumisen jälkeen 3.007e+09  3.325e-10 1.040e-223 8.693e+241

Concordance= 0.907  (se = 0 )
Likelihood ratio test= 246649  on 9 df,   p=<2e-16
Wald test            = 1155  on 9 df,   p=<2e-16
Score (logrank) test = 184663  on 9 df,   p=<2e-16
Koodi
# Poimitaan kertoimet siististi
cox_tulos <- broom::tidy(cox_fit, exponentiate = TRUE, conf.int = TRUE) |>
  mutate(
    termi_siisti = case_when(
      str_detect(term, "saadostyyppi") ~
        paste0("Type: ", str_remove(term, "saadostyyppi")),
      str_detect(term, "vuosikymmen") ~
        paste0("Decade: ", str_remove(term, "vuosikymmen")),
      str_detect(term, "kohortti") ~
        paste0("Cohort: ", str_remove(term, "kohortti")),
      TRUE ~ term
    ),
    merkitseva = p.value < 0.05
    # fct_reorder poistettu tästä
  )

ggplot(cox_tulos, aes(estimate, termi_siisti, color = merkitseva)) +
  geom_vline(xintercept = 1, linetype = "dashed",
             color = "grey50", linewidth = 0.7) +
 geom_errorbar(aes(xmin = conf.low, xmax = conf.high),
              width = 0.25, linewidth = 0.8,
              orientation = "y")  +
  geom_point(size = 3.5) +
  scale_color_manual(
    values = c("TRUE" = col_red, "FALSE" = col_blue),
    labels = c("TRUE" = "p < 0.05", "FALSE" = "p ≥ 0.05")
  ) +
  scale_x_log10(breaks = c(0.1, 0.25, 0.5, 1, 2, 4, 10),
                labels = c("0.1", "0.25", "0.5", "1", "2", "4", "10")) +
  labs(
    title    = "**Cox-regressio: hasardisuhde kumoamisriskille**",
    subtitle = "HR > 1 = suurempi kumoamisriski kuin referenssiryhmällä. Log-asteikko.",
    x = "Hasardisuhde (HR) — log-asteikko", y = NULL,
    color    = NULL,
    caption  = "Referenssi: Asetus, 1960-luku, ennen 2004. Lähde: EUR-Lex via eurlex (R).\nKristian Vepsäläinen / kristianvepsalainen.com"
  ) +
  theme(legend.position = "top")

Cox-regression hasardisuhde-metsäkaavio (forest plot). HR > 1 = suurempi kumoamisriski. Piste = estimaatti, viiva = 95 % luottamusväli.
Koodi
# Tarkistetaan proportional hazards -oletus
# Schoenfeld-jäännökset: jos korrelaatio ajan kanssa ≈ 0, oletus on ok
ph_testi <- cox.zph(cox_fit)
cat("Proportional hazards -oletus (Schoenfeld-testi):\n")
Proportional hazards -oletus (Schoenfeld-testi):
Koodi
print(ph_testi)
                chisq df       p
saadostyyppi 42.36076  3 3.4e-09
vuosikymmen   0.01041  5    1.00
kohortti      0.00534  1    0.94
GLOBAL       42.37646  9 2.8e-06
Koodi
cat("\nGlobaalin testin p-arvo:", round(ph_testi$table["GLOBAL", "p"], 4), "\n")

Globaalin testin p-arvo: 0 
Koodi
cat("(p > 0.05 = oletus ei hylätä)\n")
(p > 0.05 = oletus ei hylätä)

Kumulatiivinen hasardi: missä vaiheessa kumoamisriski on suurin?

Kaplan-Meier näyttää selviytymistodennäköisyyden. Kumulatiivinen hasardifunktio \(H(t) = -\log S(t)\) näyttää kertyneen riskin — hyödyllinen kun haluaa nähdä missä elinkaaren vaiheessa säädökset tyypillisesti kumotaan.

Koodi
km_cumhaz <- survfit(surv_obj ~ saadostyyppi, data = surv_df)

# Rakennetaan manuaalisesti ggplot2:lla jotta värit pysyvät
km_cumhaz_df <- map_dfr(
  c("Asetus", "Direktiivi", "Päätös", "Suositus"),
  function(tyyppi) {
    fit <- survfit(
      Surv(aika_v, tapaus) ~ 1,
      data = filter(surv_df, saadostyyppi == tyyppi)
    )
    tibble(
      aika         = fit$time,
      cumhaz       = fit$cumhaz,
      cumhaz_lo    = fit$cumhaz - 1.96 * fit$std.err,
      cumhaz_hi    = fit$cumhaz + 1.96 * fit$std.err,
      saadostyyppi = tyyppi
    )
  }
)

ggplot(km_cumhaz_df, aes(aika, cumhaz, color = saadostyyppi,
                          fill = saadostyyppi)) +
  geom_ribbon(aes(ymin = pmax(cumhaz_lo, 0), ymax = cumhaz_hi),
              alpha = 0.12, color = NA) +
  geom_line(linewidth = 1.0) +
  scale_color_manual(values = c(col_red, col_green, col_blue, col_orange)) +
  scale_fill_manual(values  = c(col_red, col_green, col_blue, col_orange)) +
  scale_x_continuous(limits = c(0, 70), breaks = seq(0, 65, 5)) +
  labs(
    title    = "**Kumulatiivinen hasardifunktio** tyypeittäin",
    subtitle = "Jyrkkä nousu = kumoamisriski kasautuu tuohon ikään. Nauha = 95 % luottamusväli.",
    x = "Ikä (vuosia)", y = "Kumulatiivinen hasardi H(t)",
    color = NULL, fill = NULL,
    caption  = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com"
  ) +
  theme(legend.position = "top")

Kumulatiivinen hasardifunktio tyypeittäin. Jyrkkä nousu = korkea kumoamisriski tuossa iässä. Tasainen = matala riski.

Yhteenveto

Koodi
# Mediaanieliniät tyypeittäin KM-estimaatista
km_mediaanit <- summary(km_tyypit)$table |>
  as.data.frame() |>
  rownames_to_column("tyyppi") |>
  mutate(tyyppi = str_remove(tyyppi, "saadostyyppi=")) |>
  select(tyyppi,
         n        = records,
         kumottu  = events,
         mediaani = `median`,
         lo_95    = `0.95LCL`,
         hi_95    = `0.95UCL`) |>
  mutate(across(c(mediaani, lo_95, hi_95),
                ~if_else(is.na(.), ">50 v", paste0(round(., 1), " v"))))

cat("Mediaanieliniät tyypeittäin (Kaplan-Meier):\n")
Mediaanieliniät tyypeittäin (Kaplan-Meier):
Koodi
print(km_mediaanit)
      tyyppi     n kumottu mediaani  lo_95  hi_95
1     Asetus 58206   45876   38.2 v 38.1 v 38.4 v
2 Direktiivi  4273    3084   35.4 v 34.9 v 35.9 v
3     Päätös 46774   23559   38.5 v 38.2 v 38.7 v
4   Suositus  1057     459   48.1 v 47.4 v 48.4 v

Tämä analyysi korjaa osan 1:n selviytymisharhan. Pelkkä ikäjakauma oli vääristynyt — voimassaolevat säädökset näyttivät vanhemmilta kuin ne todellisuudessa ovat, koska kaikki lyhytikäiset oli jo poistettu joukosta.

Kaplan-Meier-estimaatti ottaa sensuroinnin huomioon ja antaa realistisemman kuvan elinaikajakaumasta. Cox-regressio kvantifioi kovariaaттien vaikutukset hasardisuhteina — ei pelkkinä kertoimina, vaan suhteellisina riskeinä joilla on tulkinta.


Mitä seuraavaksi?

Osa 5 — Säädösten viittausverkosto Mitkä säädökset ovat EU-oikeuden solmupisteitä? Verkostoanalyysi (igraph/tidygraph) paljastaa juridiset riippuvuudet, joita ei voi nähdä yksittäistä säädöstä lukemalla. Solmun keskeisyys (centrality) on jakaumakin — ei yksi pistemäinen luku.


Kaikki analyysi on toistettavissa. Koodi on avoin.

Tarvitsetko eloonjäämisanalyysiä asiakaspidosta, laitevioista tai regulatoristen muutosten vaikutuksesta? — kristianvepsalainen.com