8  Метрики качества

Как и в остальных инструментах машинного обучения, при работе над морфологическими анализаторами нам нужна некоторая мера, которая покажет качество получившегося трансдьюсера. Морфологические анализаторы могут делать это на материале некоторого корпуса. В качестве игрушечного примера мы рассмотрим следующий корпус, состоящий из порошка пользователя с ником Кисычев:

```{shell}
$ cat corpus.txt
```
я пью ты пьёшь мы пьём вы пьёте
спрягал глагол уныло класс
а получился о россии
рассказ

Для целей данного занятия мы рассмотрим следующий игрушечный трансдьюсер:

```{shell}
$ cat example.lexd
```
PATTERNS
Pronouns [<PRON><nom>:]
(NounStem [<N>:] NounInflection)[^[m,f]]
Rest

LEXICON Pronouns
я
ты
мы
вы

LEXICON NounStem
глагол[m]
класс[m]
рассказ[m]
россия:росси[f]

LEXICON NounInflection
<nom><sg>:[m]
<acc><sg>:[m]
<nom><sg>:я[f]
<acc><sg>:ю[f]
<gen><sg>:и[f]

LEXICON Rest
уныло<ADV>:уныло
а<CONJ>:а
о<PREP>:о

Скомпилируем наш трансдьюсер:

```{shell}
$ lexd example.lexd | hfst-txt2fst | hfst-invert | hfst-fst2fst -O -o analyzer.hfstol
```
```{shell}
$ cat corpus.txt | hfst-proc -C analyzer.hfstol
```
"<я>"
    "я" PRON nom
"<пью>"
    "*пью"
"<ты>"
    "ты"    PRON nom
"<пьёшь>"
    "*пьёшь"
"<мы>"
    "мы"    PRON nom
"<пьём>"
    "*пьём"
"<вы>"
    "вы"    PRON nom
"<пьёте>"
    "*пьёте"
"<спрягал>"
    "*спрягал"
"<глагол>"
    "глагол"    N acc sg
    "глагол"    N nom sg
"<уныло>"
    "уныло" ADV
"<класс>"
    "класс" N acc sg
    "класс" N nom sg
"<а>"
    "а" CONJ
"<получился>"
    "*получился"
"<о>"
    "о" PREP
"<россии>"
    "россия"    N gen sg
"<рассказ>"
    "рассказ"   N acc sg
    "рассказ"   N nom sg

Сразу отметим недостатки данного трансдьюсера:

8.1 Покрытие

Покрытие (coverage, naïve coverage) — это простейший способ оценить качество трансдьюсера. Его высчитывают как долю форм, которая разбирается трансдьюсером. Посчитаем сколько токенов всего в корпусе:

```{shell}
$ cat corpus.txt | wc -w
```
17

Посчитаем, сколько токенов не разбирает трансдьюсер:

```{shell}
$ cat corpus.txt | hfst-proc -C analyzer.hfstol | grep -c "*"
```
6

Таким образом, покрытие нашего трансдьюсера приблизительно соответствует \(\frac{17-6}{17} \approx 0.65\). Не стоит сильно доверять этой мере, так как она совершенно не учитывает качество разбора, таким образом завышая качество.

Иногда вместо описанного покрытия считают аналогичную меру, удаляя повторяющиеся токены и разборы, чтобы не получалось слишком большое значение из-за того, что трансдьюсер разбирает самые частотные слова. В таком случае, описанную выше меру называют coverage1, а с удалением эффекта частотности — coverage2.

8.2 Точность и полнота

Точность (precision) и полнота (recall) — метрики, используемые при оценке большей части алгоритмов классификации в машинном обучении. Иногда их используют сами по себе, а иногда в виде производных метрик, например F1-меры. Напомним, как выглядит матрица ошибок на основе которой считаются меры:

🐕 🐈
🤖💭🐕 истинно-положительные (TP) ложно-положительные (FP)
🤖💭🐈 ложно-отрицательные объекты (FN) истинно-отрицательные (TN)

\[точность = \frac{TP}{TP+FP}\]

\[полнота = \frac{TP}{TP+FN}\]

\[F_1 = 2\times\frac{точность \times полнота}{точность + полнота} = \frac{2\times TP}{2\times TP+FP+FN}\]

В применении к морфологическому анализу, данные метрики должны учитывать качество разбора, а это значит, что нам нужно завести золотой стандарт, с которым мы будем сравнивать. Золотой стандарт не обязан иметь один единственный разбор для каждой формы, как лингвисты ожидают от оглоссированного текста. Поэтому на каждый токен в тексте мы получаем список разборов из золотого стандарта и из нашего анализатора.

есть в золотом стандарте нет в золотом стандарте
есть в анализаторе совпадения (TP) (FP)
нет в анализаторе (FN) (TN)1
  • Точность (precision) — количество разборов анализатора, совпадающих с разбором золотого стандарта (TP), деленное на количество всех разборов анализатора (TP + FP).
  • Полнота (recall) — количество разборов анализатора, совпадающих с разбором золотого стандарта (TP), деленное на колиество разборов в золотом стандарте (TP + FN).

Как разбор анализатора может совпадать с разбором золотого стандарта? Можно считать только случаи полного совпадения. Однако разумным кажется смотреть и на другие совпадения:

  • совпадение основы
  • совпадение частеречного тега
  • совпадение набора не частеречных тегов
  • совпадение основы и частеречного тега
  • совпадение частеречного тега и набора не частеречных тегов

Кроме того, можно еще проверять работу морфологического сегментатора (руками <-> рук-ами).

Вот как может выглядеть таблица с золотым стандартом. Я намеренно ввел несостыковки:

  • тег PRON вместо PR;
  • форма глагол имеет лишь один тег <aсс>, а тег <sg> пропущен;
  • в форме рассказ перепутан порядок тегов (sg, nom вместо nom, sg).

К сожалению, нам неизвестно какого-то единого инструмента, который бы подсчитывал необходимые метрики, поэтому вот некоторый код, который делает это для

  • основ;
  • частеречных тегов;
  • не частеречных тегов;
  • всего вместе.

Если будете делать сами, не забудьте несколько вещей. Во-первых, имеет смысл выкинуть дубликаты и отсортировать теги. Во-вторых, следует помнить, что отсутствие нечастеречных тегов может быть верным, а не ошибкой, например, в случае предлогов. В таком случае не стоит вводить штраф за отсутствие тегов.

Код на R и на Python используют немного разную логику.

library(tidyverse)

read_csv("examples/08_gold_standard.csv") |> 
  mutate(tags = map_chr(tags, function(x) str_split(x, ", ") |> unlist() |> unique() |> sort() |> str_c(collapse = ", ")),
         gold_standard = "gold_standard") ->
  gold_standard

system("cat examples/08_corpus.txt | hfst-proc examples/analyzer.hfstol", intern = TRUE) |> 
  str_split(" ") |> 
  unlist() |> 
  enframe() |>
  rename(token_id = name) |> 
  mutate(token = str_extract(value, "(?<=\\^).*?(?=/)"),
         analysis = str_remove(value, "^.*?/"),
         analysis = str_remove(analysis, "\\$"),
         analysis = str_split(analysis, "/")) |> 
  unnest_longer(analysis) |> 
  mutate(stem = str_remove_all(analysis, "<.*?>"),
         stem = if_else(str_detect(stem, "\\*"), "", stem),
         stem = if_else(is.na(stem), "", stem),
         pos = str_extract(analysis, "(<N>)|(<V>)|(<PRON>)|(<ADV>)|(<CONJ>)|(<PREP>)"),
         pos = if_else(is.na(pos), "", pos),
         tags = str_remove(analysis, pos),
         tags = str_remove(tags, stem),
         tags = if_else(is.na(tags), "", tags),
         tags = str_remove_all(tags, "(^<)|(>$)"),
         pos = str_remove_all(pos, "[<>]"),
         tags = map_chr(tags, function(x) str_split(x, "><") |> unlist() |> unique() |> sort() |> str_c(collapse = ", ")),
         analyzer = "analyzer") |> 
  select(token_id, token, stem, pos, tags, analyzer) ->
  analysis
  
analysis |> 
  full_join(gold_standard) |> 
  distinct() |> 
  arrange(token_id) |> 
  count(analyzer, gold_standard) |> 
  mutate(measure = case_when(!is.na(analyzer) & !is.na(gold_standard) ~ "TP",
                             is.na(analyzer) & !is.na(gold_standard) ~ "FN",
                             !is.na(analyzer) & is.na(gold_standard) ~ "FP",
                             is.na(analyzer) & is.na(gold_standard) ~ "TN")) |> 
  select(measure, n) |> 
  pivot_wider(names_from = measure, values_from = n) |> 
  summarise(overall_precision = TP/(TP+FP),
            overall_recall = TP/(TP+FN),
            overall_F_1 = 2*(overall_precision*overall_recall)/(overall_precision+overall_recall))
analysis |> 
  select(token_id, token, stem, analyzer) |> 
  full_join(gold_standard |> select(token_id, token, stem, gold_standard)) |> 
  distinct() |> 
  arrange(token_id) |> 
  count(analyzer, gold_standard) |> 
  mutate(measure = case_when(!is.na(analyzer) & !is.na(gold_standard) ~ "TP",
                             is.na(analyzer) & !is.na(gold_standard) ~ "FN",
                             !is.na(analyzer) & is.na(gold_standard) ~ "FP",
                             is.na(analyzer) & is.na(gold_standard) ~ "TN")) |> 
  select(measure, n) |> 
  pivot_wider(names_from = measure, values_from = n) |> 
  summarise(stem_precision = TP/(TP+FP),
            stem_recall = TP/(TP+FN),
            stem_F_1 = 2*(stem_precision*stem_recall)/(stem_precision+stem_recall))
analysis |> 
  select(token_id, token, stem, pos, analyzer) |> 
  full_join(gold_standard |> select(token_id, token, stem, pos, gold_standard)) |> 
  distinct() |> 
  arrange(token_id) |> 
  count(analyzer, gold_standard) |> 
  mutate(measure = case_when(!is.na(analyzer) & !is.na(gold_standard) ~ "TP",
                             is.na(analyzer) & !is.na(gold_standard) ~ "FN",
                             !is.na(analyzer) & is.na(gold_standard) ~ "FP",
                             is.na(analyzer) & is.na(gold_standard) ~ "TN")) |> 
  select(measure, n) |> 
  pivot_wider(names_from = measure, values_from = n) |> 
  summarise(stem_pos_precision = TP/(TP+FP),
            stem_pos_recall = TP/(TP+FN),
            stem_pos_F_1 = 2*(stem_pos_precision*stem_pos_recall)/(stem_pos_precision+stem_pos_recall))
analysis |> 
  select(token_id, tags, analyzer) |> 
  full_join(gold_standard |> select(token_id, tags, gold_standard)) |> 
  distinct() |> 
  arrange(token_id) |> 
  count(analyzer, gold_standard) |> 
  mutate(measure = case_when(!is.na(analyzer) & !is.na(gold_standard) ~ "TP",
                             is.na(analyzer) & !is.na(gold_standard) ~ "FN",
                             !is.na(analyzer) & is.na(gold_standard) ~ "FP",
                             is.na(analyzer) & is.na(gold_standard) ~ "TN")) |> 
  select(measure, n) |> 
  pivot_wider(names_from = measure, values_from = n) |> 
  summarise(tags_precision = TP/(TP+FP),
            tags_recall = TP/(TP+FN),
            tags_F_1 = 2*(tags_precision*tags_recall)/(tags_precision+tags_recall))
```{python}
# Возможно, удобнее посмотреть тетрадку: https://github.com/agricolamz/2025_morphological_transducers/blob/main/examples/08_quality_metrics.ipynb
import pandas as pd
import re

df_gold = pd.read_csv('08_gold_standard.csv')
df_gold['tags'] = df_gold['tags'].apply(lambda x: set(x.replace(' ','').split(',')) if str(x) !='nan' else set())
df_gold['tags'] = df_gold['tags'].apply(lambda x: '_'.join(sorted(x)))
df_gold['full'] = df_gold['stem'] + '-' + df_gold['pos'] + '-' + df_gold['tags']
df_gold

with open('analysis.txt') as f:  # (! cat 08_corpus.txt | hfst-proc -C analyzer.hfstol > analysis.txt)
    text = f.read()
    text += '\n'

analysis = {}
words = re.findall('''"<(.*?)>"\n((\t.*?\n)+)''', text)
for id, word in enumerate(words):
    stem_s = set()
    pos_s = set()
    tags_s = set()
    full_s = set()
    if not word[1].startswith('\t"*'):
        for razbor in word[1].strip('\n').split('\n'):
            stem, pos_tags = razbor.replace('"', '').strip('\t').split('\t')
            pos_tags_split = pos_tags.split(' ')
            pos = pos_tags_split[0]
            if len(pos_tags_split) > 1:
                tags = '_'.join(sorted(set(pos_tags_split[1:])))
            else:
                tags = ''
            full = stem + '-' + pos  + '-' + tags
            stem_s.add(stem)
            pos_s.add(pos)
            tags_s.add(tags)
            full_s.add(full)

    analysis[id] = {'token': word[0],
                    'stem': stem_s,
                    'pos': pos_s,
                    'tags': tags_s,
                    'full': full_s}

def get_metrics(an_part):  # an_part - то, для чего мы считаем метрики: 'stem' / 'pos' / 'tags' / 'full'
    # можно получить метрики для каждого слова, а потом посчитать среднее
    precision_s = []  # для подсчёта средних метрик
    recall_s = []
    f1_s = []
    # а можно считать метрики в конце, используя суммарные tp (good_s), fp (bad_s) и fn (not_found_s)
    good_s = 0
    bad_s = 0
    not_found_s = 0

    for id, row in df_gold.iterrows():
        gold = row[an_part]
        pred = analysis[id][an_part]

        good = 1 if gold in pred else 0  # наличие правильного предсказания
        bad = len(pred - {gold})  # кол-во неправильных предсказаний
        not_found = len({gold}) - good  # кол-во того, что не предсказали (хотя должны были)
        good_s += good
        bad_s += bad
        not_found_s += not_found

        if len(pred) != 0:
            precision = good / len(pred)  # good/(good+bad)
        else:
            precision = 1

        if len({gold}) != 0:
            recall = good / len({gold})  # good/(good+not_found)
        else:
            recall = 1
        if precision+recall != 0:
            f1 = 2*(precision*recall)/(precision+recall)
        else:
            f1 = 0

        precision_s.append(precision)
        recall_s.append(recall)
        f1_s.append(f1)

    mean_precision = sum(precision_s)/len(precision_s)
    mean_recall = sum(recall_s)/len(recall_s)
    mean_f1 = sum(f1_s)/len(f1_s)
    fin_precision = good_s/(good_s+bad_s)
    fin_recall = good_s/(good_s+not_found_s)
    fin_f1 = 2*(fin_precision*fin_recall)/(fin_precision+fin_recall)

    return {'mean_precision': mean_precision,
            'mean_recall': mean_recall,
            'mean_f1': mean_f1,
            'fin_precision': fin_precision,
            'fin_recall': fin_recall,
            'fin_f1': fin_f1}

an_part = 'full'
get_metrics(an_part)
```

Как и в случае, описанном выше, можено различать precision1, recall1 и F1-меру1 и их аналоги с суфиксом 2, если удалять эффект частотности.

8.3 Развитие анализатора

Создание морфологического парсера, согласно моим ожиданиям, это многоступенчатая процедура, которая включает в себя работу с грамматиками, словарем и проверкой некоторых мер качества на корпусе. Разработка без того или иного ресурса возможна, но значительно усложняет работу. Этот факт немного подрывает наш тезис о том, что трансдьюсеры хороши в тех случаях, когда недостаточно данных для обучения нейросетей. В любом случае, получившийся результат по духу будет значительно ближе к лингвистическому описанию, поэтому мы называем его иногда машиночитаемым лингвистическим описанием. Как и в других видах разработки, имеет смысл покрывать некоторые фрагменты кода тестами. Для этого даже придумали свою меру — code coverage, которая показывает долю кода, покрытого тестами. Конечно, такие инструменты не будут работать с теми программами, которые мы обсуждали, однако все равно, имеет смысл создавать таблицу с формами и ожидаемыми разборами, чтобы можно было проверить, например, не поломали ли мы что-то, изменяя twol правила. Такого рода проверку легко организовать в виде файла со списком форм в формате, который выдает программа hfst-fst2strings, и дальше при помощи grep смотреть, есть ли записанные разборы в выдаче hfst-fst2strings.

```{shell}
grep -xvf file_with_tests.txt generated_forms.txt
```

В какой-то момент может наступить ситуация, когда основные части речи сделаны и дополнены данными словарей. В таком случае остается только нисходяще отсортировать нераспознанные формы, чтобы работать с единицами, которые, согласно распределению Ципфа, сильнее всего увеличивают метрики.


  1. Таинственные случаи, которые целенаправлено оставлены неразобранными в золотом стандарте и не были разобраны анализатором.↩︎