L’objet du projet IA & Droit porté par l’association Open Law est de créer un jeu de données qui permette de zoner les décisions de justice des Cours d’appel Françaises. Le présent document est une illustration d’une baseline de résultat pour ce jeu de données.
La tâche consiste à qualifier chaque paragraphe d’une décision de justice selon un plan prédéfini établie par les membres d’Open Law et la Cour de cassation.
Pour rappel le dépot des données et du code source du projet est ici.
En particulier, les données brutes et déjà parsées et sérialisées au format CSV sont ici.
Pour cet exercice, il n’y a pas eu de recherche des hyper-paramètres qui pourraient être facilement améliorés.
Il est su de l’auteur de ce code que de simples modifications de ce code source (augmentation des epoch, des ngrams, etc.) permet de gagner de 1 à plusieurs points sur chaque tâche de classification.
Il est su de l’auteur de ce code que de ne pas différencier test set et dev set, c’est foncièrement mal.
C’est la raison pour laquelle il est important de prendre les présents résultats comme indicatifs de ce qui peut être fait.
L’approche choisie est une classification multiclass avec fastrtext
.
Les prédiction des types de chaque paragraphe (micro et macro) sont séparées de la prédiction de la partie concernée.
Les données sont chargées depuis le CSV
.
Ci-dessous nous affichons les premières lignes.
suppressMessages(library(data.table))
suppressMessages(library(corrplot))
library(DT)
library(fastrtext)
library(stringi)
library(assertthat)
# for reproductability
set.seed(123)
dt <- fread(input = "./annotations-clean.csv", encoding = "UTF-8")
# rename some cols
setnames(dt, c("types", "types_macro"), c("paragraph_type_micro", "paragraph_type_macro"))
print(head(dt))
## line_num paragraph_type_micro paragraph_type_macro
## 1: 1 n_a Entete
## 2: 2 n_a Entete
## 3: 3 n_a Entete
## 4: 4 n_a Entete
## 5: 5 n_a Entete
## 6: 6 n_a Entete
## annotation_difficulty text dir
## 1: Difficile COUR D'APPEL DE VERSAILLES lot_0037
## 2: Difficile Code nac : 53B lot_0037
## 3: Difficile 16e chambre lot_0037
## 4: Difficile ARRET No lot_0037
## 5: Difficile CONTRADICTOIRE lot_0037
## 6: Difficile DU 21 SEPTEMBRE 2016 lot_0037
## file
## 1: JURITEXT000033216500.txt
## 2: JURITEXT000033216500.txt
## 3: JURITEXT000033216500.txt
## 4: JURITEXT000033216500.txt
## 5: JURITEXT000033216500.txt
## 6: JURITEXT000033216500.txt
Il y a 38017 paragraphes dans le jeu de données.
Il y a 120 documents doublement annotés pour calculer l’inter-agreement.
Les doublons sont retirés.
dt <- local({
duplicated_files <- dt[,.N,.(file, dir)][, .(duplicated = duplicated(file), file, dir)][duplicated == TRUE]
dt_with_duplicated_info <- merge(dt, duplicated_files, all.x = TRUE)
dt_with_duplicated_info[is.na(duplicated)]
})
Les documents en doublons étant retirés, il reste 32744 paragraphes.
Ce comptage est fait avant le retrait de certaines catégories et/ou compression de plusieurs types en 1.
Il s’agit de donner un aperçu de la répartition des données brutes.
datatable(dt[, .(nb_mots_moyen = round(mean(stri_count_words(text))), nb_decisions = .N), paragraph_type_micro][, `%` := round(100 * nb_decisions / sum(nb_decisions), 2)])
Les annotateurs ont noté la difficulté d’annoter chaque décision.
Les décisions jugées impossibles à annoter ont été exclues du CSV
.
datatable(dt[, .(nb_paragraphes = .N), annotation_difficulty][, `%` := round(100 * nb_paragraphes / sum(nb_paragraphes), 2)])
Certains micro types de paragraphes sont regroupés.
Les paragraphes typés n_a
sont conservés.
Leur retrait améliore considérablement la qualité des prédictions.
Il est possible qu’un certain nombre de paragraphes n_a
ne devraient pas l’être.
# remove paragraph type position
dt[, paragraph_type_micro_cleaned := stri_replace_all_regex(paragraph_type_micro, "-\\d+", "")]
# remove double labels due to numbers
make_unique_labels <- function(label) {
paste(sort(unique(unlist(stri_split_fixed(label, pattern = " ")))), collapse = " ")
}
dt[, paragraph_type_micro_cleaned := sapply(paragraph_type_micro_cleaned, make_unique_labels)]
# rationalizing motifs and dispositifs
dt[, paragraph_type_micro_cleaned := ifelse(stri_detect_regex(paragraph_type_micro_cleaned, "^Motif"), ifelse(stri_detect_fixed(paragraph_type_micro_cleaned, "Motif_texte"), "Motif_texte", "Motif"), paragraph_type_micro_cleaned)]
dt[paragraph_type_micro_cleaned == "Dispositif-demandes_accessoires", paragraph_type_micro_cleaned := "Dispositif_demandes_accessoires"]
dt[paragraph_type_micro_cleaned == "Dispositif Dispositif-demandes_accessoires", paragraph_type_micro_cleaned := "Dispositif_demandes_accessoires"]
dt[paragraph_type_micro_cleaned == "Contenu_decision_attaquee Expose_litige", paragraph_type_micro_cleaned := "Contenu_decision_attaquee_Expose_litige"]
dt[paragraph_type_micro_cleaned == "Entete_appelant Entete_avocat", paragraph_type_micro_cleaned := "Entete_appelant_avec_avocat"]
dt[paragraph_type_micro_cleaned == "Entete_avocat Entete_intime", paragraph_type_micro_cleaned := "Entete_intime_avec_avocat"]
dt[, paragraph_type_micro_cleaned := stri_replace_all_regex(paragraph_type_micro_cleaned, "_intime|_appelant", "")]
dt[, position := as.numeric(seq(paragraph_type_micro_cleaned)) / length(paragraph_type_micro_cleaned), file]
dt[, intime := stri_detect_fixed(paragraph_type_micro, "_intime")]
dt[, appelant := stri_detect_fixed(paragraph_type_micro, "_appelant")]
# check that no paragraph are related to both types.
stopifnot(dt[, sum(intime & appelant)] == 0)
dt[, side := ifelse(intime | appelant, ifelse(appelant, "appelant", "intime"), "aucun")]
# Extract the first 20% and the last 20% of each decision
dt <- local({
intro <- dt[position < 0.2, .(intro = paste(text, collapse = "\n")), file]
merge(dt, intro, by = "file")
})
La transformation des paragraphes pour l’apprentissage consiste essentiellement à ajouter les paragraphes qui précèdent et suivent sous forme de contexte.
Présentement, les 3 paragraphes précédents et suivant sont ajoutés. Pour permettre au modèle de les distinguer du paragraphe à prédire, un préfixe est ajouté à chaque mot du contexte. Cette méthode augmente les résultats de plus de 10 points en fonction des tâches.
L’introduction de chaque décision (dans notre cas les 20 premiers % de chaque décision) renseigne en général sur la nature des parties et sa thématique, pour cette raison elle est aussi ajoutée au contexte de chaque paragraphe.
L’ajout de la position du paragraphe dans la décision (par tranche de 10%) ne semble pas aider la prédiction lorsque l’introduction est en contexte mais produit un effet lorsque l’introduction n’est pas ajoutée au contexte (+1/+2 points selon les taches).
add_prefix <- function(prefix, labels) {
add_prefix_item <- function(label, prefix) {
s <- stri_extract_all_boundaries(label, simplify = TRUE)
paste0(prefix, s, collapse = " ")
}
sapply(labels, FUN = add_prefix_item, prefix = prefix, USE.NAMES = FALSE)
}
swipe_features <- function(file, text, nbr) {
if (nbr > 0) {
p <- paste0("previous_", nbr, "_")
r <- add_prefix(p, c(rep("", nbr), head(text, -nbr)))
f <- c(rep("", nbr), head(file, -nbr)) == file
ifelse(f, r, "")
} else {
nbr <- abs(nbr)
p <- paste0("next_", nbr, "_")
r <- add_prefix(p, c(tail(text, -nbr), rep("", nbr)))
f <- c(tail(file, -nbr), rep("", nbr)) == file
ifelse(f, r, "")
}
}
dt[, text := stri_replace_all_regex(tolower(text), pattern = "[:punct:]", replacement = " ")]
dt[, `:=`(features_without_label = paste(swipe_features(file, text, 3), swipe_features(file, text, 2), swipe_features(file, text, 1), text, swipe_features(file, text, -1), swipe_features(file, text, -2), swipe_features(file, text, -3)), features_intro = add_prefix("intro_", intro), features_position = paste0("position_paragraphe_", 10 * round(position, 1)))]
train_rows <- seq(0.8 * nrow(dt))
test_rows <- seq(max(train_rows) + 1, nrow(dt))
L’ajout de l’ensemble de la décision en contexte peut sembler naturelle mais n’apporterait pas grand chose à la classification de chaque paragraphe :
# Original text, paragraphs 1 to 7
print(dt[1:7, text])
## [1] "ch civile a"
## [2] "arret no"
## [3] "du 13 janvier 2016"
## [4] "r g 14 00998 r"
## [5] "décision déférée à la cour jugement au fond origine tribunal de grande instance de bastia décision attaquée en date du 01 décembre 2014 enregistrée sous le no 13 01439"
## [6] "x "
## [7] "c "
# Paragraph 4 with its context (as seen by fastrtext)
print(dt[4, features_without_label])
## [1] "previous_3_ch previous_3_civile previous_3_a previous_2_arret previous_2_no previous_1_du previous_1_13 previous_1_janvier previous_1_2016 r g 14 00998 r next_1_décision next_1_déférée next_1_à next_1_la next_1_cour next_1_jugement next_1_au next_1_fond next_1_origine next_1_tribunal next_1_de next_1_grande next_1_instance next_1_de next_1_bastia next_1_décision next_1_attaquée next_1_en next_1_date next_1_du next_1_01 next_1_décembre next_1_2014 next_1_enregistrée next_1_sous next_1_le next_1_no next_1_13 next_1_01439 next_2_x next_3_c "
Deux typages peuvent être appris :
On essaye ci-dessous de deviner la nature du contenu du paragraphe.
Pour cela on va utiliser toutes les informations à notre disposition :
learn_predict <- function(features){
temp_file_train <- tempfile()
temp_file_model <- tempfile()
writeLines(dt[train_rows, sample(get(features))], con = temp_file_train)
execute(commands = c("supervised", "-input", temp_file_train, "-output", temp_file_model, "-dim", 10, "-lr", 1, "-epoch", 20, "-wordNgrams", 2, "-verbose", 0))
model <- suppressMessages(load_model(temp_file_model))
predictions <- predict(model, sentences = dt[test_rows][, get(features)], simplify = TRUE)
predicted_labels <- names(predictions)
invisible(assert_that(length(test_rows) == length(predicted_labels)))
predicted_labels
}
display_prediction_accuracy <- function(pred_of_label, label_to_pred){
tab_recall <- dt[test_rows, .(nb_mots_moyen = round(mean(stri_count_words(text))), nb_items = .N, micro_recall = round(100 * mean(get(label_to_pred) == get(pred_of_label)), 2)), get(label_to_pred)]
tab_precision <- dt[test_rows, .(micro_precision = round(100 * mean(get(label_to_pred) == get(pred_of_label)), 2)), get(pred_of_label)]
tab <- merge(tab_recall, tab_precision, by = "get")
tab[, micro_f1 := round((2 * micro_recall * micro_precision) / (micro_recall + micro_precision), digits = 2)]
setnames(tab, old = "get", label_to_pred)
datatable(tab[order(-micro_f1)])
}
dt[, features_with_type_label := paste(add_prefix("__label__", paragraph_type_micro_cleaned), features_without_label, features_intro, features_position)]
dt[test_rows, predicted_paragraph_micro := learn_predict(features = "features_with_type_label")]
display_prediction_accuracy(pred_of_label = "predicted_paragraph_micro", label_to_pred = "paragraph_type_micro_cleaned")
En moyenne, le bon type est trouvé dans 83.81% des 6549 paragraphes utilisés pour les tests.
Les prédictions se répartissent de la façon suivante :
display_errors <- function(type_to_predict, prediction) {
errors_dt <- dt[test_rows][, .(error = get(type_to_predict) != get(prediction), type_to_predict = get(type_to_predict), prediction = get(prediction))]
corr_dt <- dcast(errors_dt, type_to_predict ~ prediction, value.var = "error", fun.aggregate = length)
rows <- corr_dt[[1]]
corr_mat <- as.matrix(corr_dt[,-1])
rownames(corr_mat) <- rows
corr_mat_percent <- corr_mat / rowSums(corr_mat)
corrplot(corr_mat_percent, is.corr = FALSE, cl.lim = c(0, 1), tl.col = "black", tl.srt = 45, method = "color")
}
display_errors(type_to_predict = "paragraph_type_micro_cleaned", prediction = "predicted_paragraph_micro")
Lignes = classe à prédire
Colonnes = classe prédite
On essaye ci-dessous de deviner la partie de la décision à laquelle chaque paragraphe appartient.
Pour cela on va utiliser toutes les informations à notre disposition :
dt[, features_with_type_macro := paste(add_prefix("__label__", paragraph_type_macro), features_without_label, features_intro, features_position)]
dt[test_rows, predicted_paragraph_macro := learn_predict(features = "features_with_type_macro")]
display_prediction_accuracy(pred_of_label = "predicted_paragraph_macro", label_to_pred = "paragraph_type_macro")
En moyenne, le bon type macro est trouvé dans 95.01% des 6549 paragraphes utilisés pour les tests.
Les prédictions se répartissent de la façon suivante :
display_errors(type_to_predict = "paragraph_type_macro", prediction = "predicted_paragraph_macro")
Lignes = classe à prédire
Colonnes = classe prédite
Certains paragraphes rapportent les propos d’une des parties, dans d’autres il s’agit d’un rappel de la procédure ou c’est le magistrat qui s’exprime.
Ici on tente de deviner quelle partie au contentieux est concernée par chaque paragraphe.
Pour cela on va utiliser certaines informations à notre disposition :
Le retrait de l’introduction et de la position semble aider (+5 points sur appelant et intimé).
dt[, features_with_side_label := paste(add_prefix("__label__", side), features_without_label)]
dt[test_rows, predicted_side := learn_predict(features = "features_with_side_label")]
display_prediction_accuracy(pred_of_label = "predicted_side", label_to_pred = "side")
En moyenne, la partie concernée par un paragraphe est trouvée dans 89.36% des 6549 paragraphes utilisés pour les tests.
Les prédictions se répartissent de la façon suivante :
display_errors(type_to_predict = "side", prediction = "predicted_side")
Lignes = classe à prédire
Colonnes = classe prédite