EU:n lainsäädäntö jakaumana — Osa 5: Säädösten viittausverkosto

Mitkä säädökset ovat EU-oikeuden solmupisteitä? Verkostoanalyysi paljastaa juridiset riippuvuudet, joita ei voi nähdä yksittäistä säädöstä lukemalla.

Tekijä

Kristian Vepsäläinen

Julkaistu

25.6.2026

Näytä koodi
library(tidyverse)
library(here)
library(lubridate)
library(scales)
library(ggtext)
library(igraph)
library(tidygraph)
library(ggraph)
library(patchwork)
#library(eurlex)

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

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()
    )
)

Verkosto on jakauma — keskeisyyskin on jakauma

EU-oikeuden julkisessa keskustelussa puhutaan yksittäisistä säädöksistä. GDPR. NIS2. AI Act. Jokainen niistä esitetään itsenäisenä kokonaisuutena — ikään kuin se olisi syntynyt tyhjiöön.

Todellisuudessa EU-lainsäädäntö on verkosto. Säädökset viittaavat toisiinsa, muuttavat toisiaan, nojaavat toistensa määritelmiin. Jotkut säädökset ovat verkoston solmupisteitä (hubs): jos ne kumotaan tai muuttuvat, kymmenien muiden säädösten tulkinta muuttuu samalla.

Tämä on “maailma on jakauma” -teeman ydin: keskeisyyskään ei ole pistemäinen ominaisuus. Se on jakauma. Useimmat säädökset ovat verkoston reunoilla — harva on solmupisteessä. Jakauman häntä on se, mikä kiinnostaa.


Datan haku: include_citations

Aiemmissa sarjan osissa havaitsimme että include_lbs = TRUE hakee oikeusperustan (Treaty-artiklaviittaukset UUID-muodossa) — ei säädösviittauksia. Verkostoanalyysiin tarvitaan include_citations = TRUE, joka palauttaa suorat viittaukset toisiin säädöksiin CELEX-muodossa.

Näytä koodi
cit_path  <- here("data/eu/eu_citations_raw.rds")
base_path <- here("data/eu/eu_saadanto_raw.rds")

if (!file.exists(base_path)) {
  stop("Aja ensin osa 1: ", base_path, " puuttuu.")
}
raw_base <- readRDS(base_path)
message("Perustilastot ladattu: ", nrow(raw_base), " riviä")

if (!file.exists(cit_path)) {
  message("Haetaan viittausdata EUR-Lexistä (include_citations = TRUE)...")
  message("Huomio: haku voi kestää useita minuutteja.")

  tyypit <- c("regulation", "directive", "decision")

  hae_viittaukset <- function(tyyppi) {
    message("  → ", tyyppi)
    tulos <- elx_make_query(
      resource_type     = tyyppi,
      include_date      = TRUE,
      include_celex     = TRUE,
      include_citations = TRUE
    ) |> elx_run_query() |>
      mutate(resource_type = tyyppi)

    cat(tyyppi, ":", nrow(tulos), "riviä —",
        if (nrow(tulos) == 1e6) "*** RAJA OSUI ***\n" else "ok\n")
    tulos
  }

  raw_cit <- map(tyypit, hae_viittaukset) |> bind_rows()
  dir.create(dirname(cit_path), showWarnings = FALSE, recursive = TRUE)
  saveRDS(raw_cit, cit_path)
  message("Tallennettu: ", nrow(raw_cit), " riviä → ", cit_path)

} else {
  raw_cit <- readRDS(cit_path)
  message("Ladattu tiedostosta: ", nrow(raw_cit), " riviä")
}

# Tarkistetaan sarakkeet
cat("Sarakkeet:", paste(names(raw_cit), collapse = ", "), "\n")
Sarakkeet: work, type, celex, date, citationcelex, resource_type 
Näytä koodi
cat("Rivejä yhteensä:", nrow(raw_cit), "\n\n")
Rivejä yhteensä: 374259 
Näytä koodi
# Katsotaan miltä citations-data näyttää
cat("Esimerkki viittausdatasta:\n")
Esimerkki viittausdatasta:
Näytä koodi
raw_cit |>
  filter(!is.na(citationcelex)) |>
  select(celex, citationcelex) |>
  slice_head(n = 5) |>
  print()
       celex citationcelex
1 31994R3141    31993R3376
2 31968Q0313     11965F020
3 31973R0715    31972R0805
4 31964Q0356    31968Q0313
5 31971Q0068    31968Q0313
Näytä koodi
# Poimitaan CELEX-numero citations-sarakkeesta
# Rakenne voi olla URI-muoto: http://...celex/32024R0903
# tai suoraan CELEX: 32024R0903
# str_extract tunnistaa molemmat

reunat_raw <- raw_cit |>
  filter(!is.na(celex), !is.na(citationcelex)) |>
  mutate(
    lahde = celex,
    # Ensin yritetään poimia URI:n lopusta
    kohde = coalesce(
      str_extract(citationcelex, "(?<=celex/)[A-Z0-9]+"),
      # Jos ei URI-muoto, yritetään suoraa CELEX-muotoa
      str_extract(citationcelex, "^[0-9]{5}[A-Z][0-9]+$")
    )
  ) |>
  filter(
    !is.na(kohde),
    lahde != kohde,           # ei itseen viittaavia silmukoita
    nchar(kohde) >= 5,        # poistetaan liian lyhyet
    nchar(kohde) <= 20        # poistetaan liian pitkät
  ) |>
  select(lahde, kohde) |>
  distinct()

cat("Viittauspareja (reunoja):", nrow(reunat_raw), "\n")
Viittauspareja (reunoja): 190554 
Näytä koodi
cat("Uniikkeja lähdesäädöksiä:", n_distinct(reunat_raw$lahde), "\n")
Uniikkeja lähdesäädöksiä: 72836 
Näytä koodi
cat("Uniikkeja kohdesäädöksiä:", n_distinct(reunat_raw$kohde), "\n\n")
Uniikkeja kohdesäädöksiä: 31263 
Näytä koodi
# Tarkistetaan että kohteet näyttävät oikeilta CELEX-numeroilta
cat("Esimerkkejä kohde-CELEX:eistä:\n")
Esimerkkejä kohde-CELEX:eistä:
Näytä koodi
head(reunat_raw$kohde, 10)
 [1] "31993R3376" "11965F020"  "31972R0805" "31968Q0313" "31968Q0313"
 [6] "31968Q0313" "31968Q0313" "31972R2484" "32004R2276" "32004R2276"
Näytä koodi
# Viittausten jakauma per säädös
cat("Viittauksia per lähdesäädös (lähtevä aste):\n")
Viittauksia per lähdesäädös (lähtevä aste):
Näytä koodi
reunat_raw |>
  count(lahde, name = "viittauksia") |>
  pull(viittauksia) |>
  summary() |>
  print()
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  1.000   1.000   2.000   2.616   3.000  99.000 
Näytä koodi
cat("\nViittauksia per kohdesäädös (saapuva aste):\n")

Viittauksia per kohdesäädös (saapuva aste):
Näytä koodi
reunat_raw |>
  count(kohde, name = "viittauksia") |>
  pull(viittauksia) |>
  summary() |>
  print()
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  1.000   1.000   1.000   6.095   3.000 919.000 

Verkostorakenne

Näytä koodi
# Solmumeta perustilastoista
solmut_meta <- raw_base |>
  filter(!is.na(celex), !is.na(date)) |>
  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",
      TRUE                              ~ "Muu"
    )
  ) |>
  select(celex, saadostyyppi, vuosi) |>
  distinct(celex, .keep_all = TRUE)

# Kaikki uniikkit solmut
solmut_celex <- union(reunat_raw$lahde, reunat_raw$kohde)

# Rakennetaan igraph suoraan — luotettavampi kuin tidygraph-muunnos
g_igraph <- graph_from_data_frame(
  d = reunat_raw |> rename(from = lahde, to = kohde),
  directed = TRUE,
  vertices = tibble(name = solmut_celex) |>
    left_join(solmut_meta, by = c("name" = "celex"))
)

cat("Verkosto rakennettu:\n")
Verkosto rakennettu:
Näytä koodi
cat("  Solmuja:", vcount(g_igraph), "\n")
  Solmuja: 84050 
Näytä koodi
cat("  Reunoja:", ecount(g_igraph), "\n")
  Reunoja: 190554 
Näytä koodi
cat("  Tiheys:", round(edge_density(g_igraph), 6), "\n")
  Tiheys: 2.7e-05 
Näytä koodi
cat("  Solmuattribuutit:", paste(vertex_attr_names(g_igraph), collapse = ", "), "\n")
  Solmuattribuutit: name, saadostyyppi, vuosi 
Näytä koodi
cat("Lasketaan keskeisyysmitat...\n")
Lasketaan keskeisyysmitat...
Näytä koodi
indegree    <- degree(g_igraph, mode = "in")
outdegree   <- degree(g_igraph, mode = "out")
pagerank    <- page_rank(g_igraph, directed = TRUE)$vector
betweenness <- betweenness(g_igraph, directed = TRUE, normalized = TRUE)

# Kootaan — V(g_igraph)$name on nyt luotettava koska
# rakensimme igraphin graph_from_data_frame:lla
solmut_df <- tibble(
  celex       = V(g_igraph)$name,
  indegree    = indegree,
  outdegree   = outdegree,
  pagerank    = pagerank,
  betweenness = betweenness
) |>
  left_join(solmut_meta, by = "celex") |>
  arrange(desc(indegree))

cat("Top 10 saapuvan asteen mukaan:\n")
Top 10 saapuvan asteen mukaan:
Näytä koodi
solmut_df |>
  slice_head(n = 10) |>
  select(celex, saadostyyppi, vuosi, indegree, pagerank) |>
  mutate(pagerank = round(pagerank, 5)) |>
  print()
# A tibble: 10 × 5
   celex      saadostyyppi vuosi indegree pagerank
   <chr>      <chr>        <dbl>    <dbl>    <dbl>
 1 31992R2913 Asetus        1992      919  0.00355
 2 31993R1068 Asetus        1993      879  0.00178
 3 31999D0468 Päätös        1999      872  0.00467
 4 31995R1501 Asetus        1995      808  0.00245
 5 31992R3813 Asetus        1992      777  0.00115
 6 32011R0182 Asetus        2011      761  0.00135
 7 32018R1725 Asetus        2018      751  0.00102
 8 31975R0584 Asetus        1975      728  0.00182
 9 31987R2200 Asetus        1987      725  0.00153
10 31987R1420 Asetus        1987      692  0.00139

Analyysi 1: Keskeisyysjakaumat — potenssilaki?

Näytä koodi
indegree_dist <- solmut_df |>
  count(indegree, name = "n_acts") |>
  filter(indegree > 0)

p1 <- ggplot(indegree_dist, aes(indegree, n_acts)) +
  geom_point(color = col_navy, alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = TRUE,
              color = col_red, fill = col_red, alpha = 0.15) +
  scale_x_log10(labels = comma_format()) +
  scale_y_log10(labels = comma_format()) +
  labs(
    title    = "**Saapuvan asteen jakauma** (log-log)",
    subtitle = "Suora viiva = potenssilaki. Harvoilla säädöksillä äärimmäisen paljon viittauksia.",
    x = "Saapuva aste (log)", y = "Lukumäärä (log)"
  )

p2 <- solmut_df |>
  filter(pagerank > 0) |>
  ggplot(aes(pagerank)) +
  geom_histogram(aes(y = after_stat(density)),
                 bins = 60, fill = col_blue,
                 alpha = 0.7, color = "white") +
  geom_density(color = col_red, linewidth = 1.0) +
  scale_x_log10(labels = scientific_format()) +
  labs(
    title    = "**PageRank-jakauma** (log-asteikko)",
    subtitle = "Äärimmäisen vino oikealle — solmupisteet erottuvat selvästi massasta",
    x = "PageRank (log)", y = "Tiheys",
    caption  = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com"
  )

p1 / p2

Saapuvan asteen jakauma log-log-asteikolla ja PageRank-jakauma. Suora linja log-log-kuvassa viittaa potenssilakijakaumaan.
Näytä koodi
solmut_df |>
  filter(!is.na(saadostyyppi), saadostyyppi != "Muu", pagerank > 0) |>
  ggplot(aes(pagerank, fill = saadostyyppi, color = saadostyyppi)) +
  geom_density(alpha = 0.35, linewidth = 0.8) +
  scale_fill_manual(values  = c(col_red, col_green, col_blue, col_orange)) +
  scale_color_manual(values = c(col_red, col_green, col_blue, col_orange)) +
  scale_x_log10(labels = scientific_format()) +
  labs(
    title    = "**PageRank-jakauma säädöstyypeittäin**",
    subtitle = "Oikealla häntä = solmupisteet. Mikä tyyppi tuottaa eniten solmupisteitä?",
    x = "PageRank (log)", y = "Tiheys",
    fill = NULL, color = NULL,
    caption  = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com"
  ) +
  theme(legend.position = "top")

PageRank-jakauma säädöstyypeittäin.

Analyysi 2: Solmupisteet — EU-oikeuden peruskivet

Näytä koodi
top20 <- solmut_df |>
  arrange(desc(pagerank)) |>
  slice_head(n = 20) |>
  mutate(pagerank_norm = pagerank / max(pagerank))

top20 |>
  mutate(celex = fct_reorder(celex, pagerank)) |>
  ggplot(aes(pagerank_norm, celex, fill = saadostyyppi)) +
  geom_col(show.legend = TRUE, width = 0.75) +
  geom_text(
    aes(label = paste0(
      if_else(!is.na(vuosi), as.character(vuosi), "?"),
      " · ",
      if_else(!is.na(saadostyyppi), saadostyyppi, "?")
    )),
    hjust = -0.05, size = 3.0
  ) +
  scale_fill_manual(values = c(col_red, col_green, col_blue,
                               col_orange, col_navy),
                    na.value = "grey60") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.35)),
                     labels = percent_format()) +
  labs(
    title    = "**EU-oikeuden solmupisteet** — top 20 PageRankin mukaan",
    subtitle = "100 % = suurin PageRank verkostossa. Nämä säädökset ovat muiden perusta.",
    x = "Suhteellinen PageRank", y = NULL, fill = NULL,
    caption  = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com"
  ) +
  theme(legend.position = "top")

Top 20 säädöstä PageRankin mukaan.

Analyysi 3: Verkostovisualisointi — ytimen rakenne

Näytä koodi
kynnys_95 <- quantile(solmut_df$indegree, 0.95, na.rm = TRUE)
cat("95. persentiili indegree:", kynnys_95, "\n")
95. persentiili indegree: 7 
Näytä koodi
ydin_celex <- solmut_df |>
  filter(indegree >= kynnys_95) |>
  pull(celex)

cat("Ydinsolmuja:", length(ydin_celex), "\n")
Ydinsolmuja: 4375 
Näytä koodi
g_ydin <- induced_subgraph(
  g_igraph,
  vids = which(V(g_igraph)$name %in% ydin_celex)
)

cat("Ydinverkoston solmuja:", vcount(g_ydin), "\n")
Ydinverkoston solmuja: 4375 
Näytä koodi
cat("Ydinverkoston reunoja:", ecount(g_ydin), "\n")
Ydinverkoston reunoja: 19439 
Näytä koodi
pagerank_ydin <- page_rank(g_ydin, directed = TRUE)$vector

g_ydin_tidy <- as_tbl_graph(g_ydin) |>
  mutate(
    pagerank = pagerank_ydin,
    etiketti = if_else(
      pagerank >= quantile(pagerank_ydin, 0.90, na.rm = TRUE),
      name,
      NA_character_
    )
  )

set.seed(42)
ggraph(g_ydin_tidy, layout = "fr") +
  geom_edge_arc(
    aes(alpha = after_stat(index)),
    color       = "grey70",
    linewidth   = 0.3,
    show.legend = FALSE
  ) +
  geom_node_point(
    aes(size = pagerank,
        color = saadostyyppi),
    alpha = 0.85
  ) +
  geom_node_text(
    aes(label = etiketti),
    size         = 2.8,
    repel        = TRUE,
    max.overlaps = 20,
    color        = col_navy,
    na.rm        = TRUE
  ) +
  scale_color_manual(
    values   = c(col_red, col_green, col_blue, col_orange, col_navy),
    na.value = "grey60"
  ) +
  scale_size_continuous(range = c(1.5, 8), guide = "none") +
  scale_edge_alpha(range = c(0.05, 0.3), guide = "none") +
  labs(
    title    = "**EU-oikeuden ydinverkosto** — top 5 % keskeisyyden mukaan",
    subtitle = "Solmun koko = PageRank. Etiketti = kaikkein keskeisimmät säädökset.",
    color    = NULL,
    caption  = "Layout: Fruchterman-Reingold. Lähde: EUR-Lex SPARQL via eurlex (R).\nKristian Vepsäläinen / kristianvepsalainen.com"
  ) +
  theme_graph(base_family = "sans", base_size = 12) +
  theme(
    plot.title      = element_markdown(face = "bold", size = 15),
    plot.subtitle   = element_text(color = "grey40"),
    plot.caption    = element_text(color = "grey55", size = 9),
    legend.position = "top"
  )

EU-oikeuden ydinverkosto — top 5 % indegree-asteen mukaan. Solmun koko = PageRank. Väri = säädöstyyppi.

Analyysi 4: Keskeisyyden kehitys ajan myötä

Näytä koodi
solmut_df |>
  filter(!is.na(vuosi), vuosi >= 1960, vuosi <= 2019, pagerank > 0) |>
  mutate(
    vuosikymmen = factor(
      paste0(floor(vuosi / 10) * 10, "-luku"),
      levels = paste0(seq(1960, 2010, 10), "-luku")
    )
  ) |>
  ggplot(aes(vuosikymmen, pagerank, fill = vuosikymmen)) +
  geom_violin(alpha = 0.65, color = "white", show.legend = FALSE) +
  geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA,
               color = "grey30", show.legend = FALSE) +
  scale_fill_manual(values = c(col_navy, col_blue, col_green,
                               col_orange, col_red, col_purple)) +
  scale_y_log10(labels = scientific_format()) +
  labs(
    title    = "**PageRank-jakauma vuosikymmenittäin**",
    subtitle = "Log-asteikko. Mediaani laskee uudemmissa vuosikymmenissä — uudet säädökset eivät ole ehtineet kerätä viittauksia.",
    x = NULL, y = "PageRank (log)",
    caption  = "Lähde: EUR-Lex SPARQL via eurlex (R). Kristian Vepsäläinen / kristianvepsalainen.com"
  )

PageRank-jakauma vuosikymmenittäin — viuludiagrammi paljastaa muodon muutokset.

Yhteenveto

Tässä sarjan viidennessä osassa rakennettiin EU:n lainsäädännön viittausverkosto käyttäen include_citations = TRUE -parametria.

Tekninen huomio: include_lbs hakee oikeusperustan Treaty-artiklaviittaukset UUID-muodossa — ei säädösviittauksia. include_citations on oikea parametri verkostoanalyysiin.

Verkostorakenne noudattaa potenssilakia: useimmilla säädöksillä on vähän viittauksia, harvoilla äärimmäisen paljon. Tällaisessa verkostossa muutama solmupiste on kriittinen koko oikeusjärjestelmän kannalta — niiden muuttuminen vaikuttaa kaikkiin säädöksiin jotka niihin viittaavat.


Mitä seuraavaksi?

Osa 6 — EP:n poliittiset voimasuhteet ja säädöstuotanto Vaikuttaako EP:n oikeisto–vasemmisto-painopiste siihen mitä EU lainsäätää? ParlGov + EUR-Lex + Bayesilainen regressio.


Kaikki analyysi on toistettavissa. Koodi on avoin.

Kiinnostaako verkostoanalyysi omassa liiketoiminnassasi? — kristianvepsalainen.com