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.

1 Pré-traitements

1.1 Chargement des librairies et lecture des données

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.

1.2 Retrait des paragraphes doublons

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.

1.3 Comptage des types

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

1.4 Répartition de la difficulté d’annotation

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

1.5 Groupement de certains types

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")
})

1.6 Préparation des données

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 :

  • il n’y a plus de lien direct entre le paragraphe à caractériser et ce contexte large ;
  • l’information sur la nature de la décision se trouve déjà, dans les grandes lignes, dans l’introduction déjà donnée en contexte ;
  • tous les paragraphes d’une même décision auraient le même contexte, ce contexte serait ignoré par l’algorithme.

1.6.1 Paragraphe avec contexte tel que vu par l’algorithme

# 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 "

2 Apprentissages

2.1 Typage des paragraphes

Deux typages peuvent être appris :

  • un micro typage qui renseigne sur la nature du contenu de chaque paragraphe ;
  • un typage davantage macro qui permet d’avoir une idée du plan de la décision.

2.1.1 Micro typage

On essaye ci-dessous de deviner la nature du contenu du paragraphe.
Pour cela on va utiliser toutes les informations à notre disposition :

  • le contenu du paragraphe ;
  • ses différents contextes ;
  • sa position.
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

2.1.2 Macro typage

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 :

  • le contenu du paragraphe ;
  • ses différents contextes ;
  • sa position.
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

2.2 Partie concernée par un paragraphe

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 contenu du paragraphe ;
  • ses différents contextes (pargraphes qui suivent ou qui précèdent).

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