Workshop · Umeå University

Computational Legal Methods

A hands-on introduction to R and the core computational legal methods — measured against one concrete case: how similar are Swedish Supreme Court decisions?

I

The case

Case similarity is a realistic, common legal task with clear practical uses. This workshop measures how similar every other judgment is to one particular judgment — the anchor case, NJA 2019 s. 684 (mopedbilen) — by comparing more than 2,500 Swedish Supreme Court decisions from the sehc project.

Along the way you will learn the basics of R, the jack-of-all-trades language of quantitative legal research. After the workshop you will be ready to run computational analysis on your own data by changing only a few lines of code — from processing text to producing publication-ready figures and tables.

II

Getting ready

Setup

Five steps, 15–30 minutes

start early, in case something goes wrong
1Get the materialsDownload the workshop folder from https://github.com/jojolindholm/clm_workshop (green “Code” button → “Download zip”) and extract it. Remember where the folder is — you will work in it all day.https://github.com/jojolindholm/clm_workshop
2Install RR contains the basic files that let your computer run R code. Install the version matching your operating system from CRAN.https://mirror.accum.se/mirror/CRAN/
3Install RStudioRStudio makes it easier to explore data, write, test and run R code. Get it from Posit.https://docs.posit.co/ide/user/#direct-downloads-open-source
4Install the R packagesPackages contain functions beyond base R. Launch RStudio (opening clm_intro.Rproj works) and run the line below in the console. Takes a few minutes.install.packages(c("tidyverse", "tidytext", "Matrix", "SnowballC", "doc2vec", "igraph", "jsonlite", "glue"))
5Open the projectDouble-click clm_intro.Rproj. It opens the project in RStudio; with the folder visible in the Files tab you are ready to start.clm_intro.Rproj
Stuck? Book a meeting ↗ for help with setting up your machine.
III

Quantitative text analysis

The first part of the workshop treats each judgment as a text to be measured. TF-IDF gives every word in every judgment a weight, high when the word is frequent in the judgment but rare in the corpus; each judgment becomes a vector of its distinctive words, and similarity is the cosine of those vectors.

Method III

TF-IDF

bag-of-words + cosine similarity

Strengths

Fast and interpretable — you can inspect exactly which words drove a match. No training data needed.

Limitations

A bag of words: only exact word overlap counts. “Transfer fee” and “registration fee” are treated as unrelated.

R
# step 1 — build the corpus: one document per judgment, in reading order
corpus <- text |>
  arrange(case_num, para_num) |>
  group_by(case_num) |>
  summarise(text = paste(text, collapse = " "))

corpus_facts <- text |>                      # only the "intro" (facts) sections
  filter(section_class == "intro") |>
  arrange(case_num, para_num) |>
  group_by(case_num) |>
  summarise(text = paste(text, collapse = " "))

# step 2 — tf-idf: one vector of distinctive words per judgment
corpus_tfidf <- corpus |>
  unnest_tokens(word, text) |>
  mutate(word = SnowballC::wordStem(word, language = "swedish")) |>
  count(case_num, word, sort = TRUE) |>
  bind_tf_idf(word, case_num, n)

# which words distinguish the anchor case (NJA 2019 s. 684)?
corpus_tfidf |>
  filter(case_num == anchor_case) |>
  slice_max(tf_idf, n = 10) |>
  select(word, n, tf_idf)

# cosine similarity between every judgment and the anchor case
m <- cast_sparse(corpus_tfidf, case_num, word, tf_idf)
target <- as.numeric(m[anchor_case, ])
cosine <- as.numeric(m %*% target) /
  (sqrt(Matrix::rowSums(m^2)) * sqrt(sum(target^2)))
names(cosine) <- rownames(m)

# top-10 by tf-idf cosine similarity
top_10_tfidf <- tibble(case_num = names(cosine), similarity = as.numeric(cosine)) |>
  left_join(cases |> select(case_num, publ_num, court_header), by = "case_num") |>
  filter(case_num != anchor_case) |>
  arrange(desc(similarity)) |>
  slice_head(n = 10) |>
  mutate(similarity = round(similarity, 3))
Method III·b

doc2vec

dense vectors that respect word order

Strengths

Words used in similar ways end up close, so two judgments can be similar even with few shared exact words.

Limitations

A black box — harder to explain why two cases ended up near each other; requires training time and computational cost.

R
# step 3 — doc2vec: a dense vector per judgment, learned from word order
# train on the facts only, with clean lowercase text
facts_clean <- corpus_facts |>
  mutate(
    doc_id = str_replace_all(case_num, " ", "_"),
    text = tolower(text),
    text = str_replace_all(text, "[^a-zåäö ]", " "),
    text = str_squish(text)
  ) |>
  mutate(text = map_chr(text, ~ paste(head(str_split(.x, " ")[[1]], 1000), collapse = " ")))

# learn one 50-dimensional vector per judgment
facts_doc2vec <- paragraph2vec(
  x = as.data.frame(facts_clean[, c("doc_id", "text")]),
  type = "PV-DBOW", dim = 50, iter = 10, min_count = 2, threads = 1
)
facts_vectors <- as.matrix(facts_doc2vec, which = "docs")

# cosine similarity to the anchor case
anchor_id <- str_replace_all(anchor_case, " ", "_")
facts_cosine <- as.numeric(facts_vectors %*% facts_vectors[anchor_id, ])

top_10_doc2vec <- tibble(doc_id = names(facts_cosine), similarity = as.numeric(facts_cosine)) |>
  left_join(facts_clean |> select(doc_id, case_num), by = "doc_id") |>
  left_join(cases |> select(case_num, publ_num, court_header), by = "case_num") |>
  filter(doc_id != anchor_id) |>
  arrange(desc(similarity)) |>
  slice_head(n = 10) |>
  mutate(similarity = round(similarity, 3))
IV

Network analysis

Judgments also relate to each other by citation. The second part of the workshop builds the citation graph, maps the anchor case’s ego network, and groups the corpus into communities with Louvain — ranking the members of the anchor’s community by PageRank.

Method IV

Citation networks

ego networks · communities · PageRank

Strengths

Language-independent and grounded in legal reasoning — shared precedent signals a shared legal problem space.

Limitations

Depends on citation density; early or niche judgments have few citations. Needs a structured citation table.

R
# step 1 — the citation graph
g <- graph_from_data_frame(
  citation_edges |>
    filter(!is.na(cited_case), cited_case != citing_case) |>
    select(citing_case, cited_case),
  directed = TRUE
)

# step 2 — the anchor case's ego network (neighbours within two degrees)
anchor_case_neighbors <- neighborhood(g, order = 2, anchor_case)
ego_g <- induced_subgraph(g, anchor_case_neighbors[[1]])
ego_g <- simplify(ego_g, edge.attr.comb = "sum")

plot(ego_g,
     vertex.color = ifelse(V(ego_g)$name == anchor_case, "#E8A33D", "#9FB1CC"))

# step 3 — communities (Louvain) and PageRank within the anchor's community
g_undirected <- as_undirected(g, mode = "collapse")
communities <- cluster_louvain(g_undirected)
anchor_comm_id <- communities$membership[names(V(g)) == anchor_case]

pagerank <- page_rank(g, directed = TRUE)$vector
pagerank_table <- tibble(case_num = names(pagerank), pagerank = pagerank)

top_10_community <-
  tibble(case_num = V(g)$name[communities$membership == anchor_comm_id]) |>
  left_join(pagerank_table, by = "case_num") |>
  left_join(select(cases, case_num, publ_num, court_header), by = "case_num") |>
  filter(case_num != anchor_case) |>
  arrange(desc(pagerank)) |>
  mutate(norm_pagerank = pagerank / max(pagerank)) |>
  head(10)
V

LLMs

The approaches disagree, which is the point. The final part puts the four top-10 lists side by side as a heatmap, then hands the same question to an LLM — feeding it the judgment texts as context and asking for a structured verdict with a reason.

Method V

LLM comparison

the same question, asked of a machine that reads

Strengths

Handles context and reasoning; explains its verdict. The same question as the statistical approaches, directly comparable.

Limitations

Costs money, needs an API key, and the verdict is hard to reproduce exactly. A black box you must audit.

R
# step 1 — put the four approaches side by side
compare <- tibble(case_num = unique(c(
  top_10_neighbors, top_10_community$case_num,
  top_10_tfidf$case_num, top_10_doc2vec$case_num
))) |>
  mutate(
    neighbors = as.integer(case_num %in% top_10_neighbors),
    community = top_10_community$norm_pagerank[match(case_num, top_10_community$case_num)],
    tfidf     = top_10_tfidf$similarity[match(case_num, top_10_tfidf$case_num)],
    doc2vec   = top_10_doc2vec$similarity[match(case_num, top_10_doc2vec$case_num)]
  ) |>
  left_join(cases |> select(case_num, publ_num), by = "case_num") |>
  pivot_longer(cols = -c(case_num, publ_num),
               names_to = "approach", values_to = "value") |>
  replace_na(list(value = 0))

# green = similar, red = not similar
compare |>
  ggplot(aes(x = approach, y = publ_num, fill = value)) +
  geom_tile(colour = "white", linewidth = 1) +
  scale_fill_gradient(low = "#C0392B", high = "#27AE60", limits = c(0, 1))

# step 2 — let an LLM judge the same candidates, text as context
api_url   <- "https://openrouter.ai/api/v1/chat/completions"
api_key   <- "sk-or-v1-…your key…"              # replace with your own key
api_model <- "openai/gpt-oss-20b"

fill_prompt <- function(candidate_num) {
  candidate_text <- get_judgment_text(candidate_num)
  glue(llm_prompt$user,
       anchor_case = anchor_label,
       anchor_text = anchor_text,
       candidate_case = candidate_num,
       candidate_text = candidate_text,
       .open = "{{", .close = "}}")
}

# loop over every candidate, collect the LLM's verdict
for (i in 1:length(candidates)) {
  prompt_text <- fill_prompt(candidates[i])
  resp <- httr::POST(api_url,
                     httr::add_headers(Authorization = paste("Bearer", api_key)),
                     body = list(
                       model = api_model,
                       temperature = 0,
                       messages = list(
                         list(role = "system", content = llm_prompt$system),
                         list(role = "user", content = prompt_text)
                       )
                     ),
                     encode = "json")
  ans <- httr::content(resp, as = "text", encoding = "UTF-8")
  msg <- fromJSON(ans, simplifyVector = FALSE)$choices[[1]]$message$content
  verdict <- tryCatch(fromJSON(msg, simplifyVector = FALSE), error = function(e) NULL)
  similar <- isTRUE(verdict$similar)
  reason  <- verdict$reason
  if (is.null(reason)) { similar <- grepl("true", tolower(msg)); reason <- substr(msg, 1, 200) }
  llm_results <- bind_rows(llm_results, tibble(case_num = candidates[i], similar = similar, reason = reason))
}
VI

Files & materials

Repo

What’s in the folder

get it on GitHub ↗
01_quantitative_text_analysis.RPart 1: quantitative text analysis (TF-IDF, doc2vec, cosine similarity)script
02_network_analysis.RPart 2: network analysis (ego networks, communities, PageRank)script
03_llms.RPart 3: comparing approaches and calling an LLM from Rscript
cases.csvCase-level metadata, one row per judgmentdata
opinions.csvOpinion-level metadata, one row per opiniondata
text.csvJudgment text, one row per paragraphdata
citation_edges.csvOne row per citation from one judgment to anotherdata
prompt_eval.jsonStructured JSON instruction for the LLM promptdata
clm_intro.RprojRStudio project file for managing the workshopproject
VII

Further reading

Correspondence

Questions?

← Back to Teaching