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?
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.
Getting ready
Five steps, 15–30 minutes
start early, in case something goes wrongQuantitative 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.
TF-IDF
bag-of-words + cosine similarityStrengths
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.
# 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))doc2vec
dense vectors that respect word orderStrengths
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.
# 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))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.
Citation networks
ego networks · communities · PageRankStrengths
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.
# 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)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.
LLM comparison
the same question, asked of a machine that readsStrengths
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.
# 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))
}