я пью ты пьёшь мы пьём вы пьёте
спрягал глагол уныло класс
а получился о россии
рассказ
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
= 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[
df_gold
with open('analysis.txt') as f: # (! cat 08_corpus.txt | hfst-proc -C analyzer.hfstol > analysis.txt)
= f.read()
text += '\n'
text
= {}
analysis = re.findall('''"<(.*?)>"\n((\t.*?\n)+)''', text)
words for id, word in enumerate(words):
= set()
stem_s = set()
pos_s = set()
tags_s = set()
full_s if not word[1].startswith('\t"*'):
for razbor in word[1].strip('\n').split('\n'):
= razbor.replace('"', '').strip('\t').split('\t')
stem, pos_tags = pos_tags.split(' ')
pos_tags_split = pos_tags_split[0]
pos if len(pos_tags_split) > 1:
= '_'.join(sorted(set(pos_tags_split[1:])))
tags else:
= ''
tags = stem + '-' + pos + '-' + tags
full
stem_s.add(stem)
pos_s.add(pos)
tags_s.add(tags)
full_s.add(full)
id] = {'token': word[0],
analysis['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)
= 0
good_s = 0
bad_s = 0
not_found_s
for id, row in df_gold.iterrows():
= row[an_part]
gold = analysis[id][an_part]
pred
= 1 if gold in pred else 0 # наличие правильного предсказания
good = len(pred - {gold}) # кол-во неправильных предсказаний
bad = len({gold}) - good # кол-во того, что не предсказали (хотя должны были)
not_found += good
good_s += bad
bad_s += not_found
not_found_s
if len(pred) != 0:
= good / len(pred) # good/(good+bad)
precision else:
= 1
precision
if len({gold}) != 0:
= good / len({gold}) # good/(good+not_found)
recall else:
= 1
recall if precision+recall != 0:
= 2*(precision*recall)/(precision+recall)
f1 else:
= 0
f1
precision_s.append(precision)
recall_s.append(recall)
f1_s.append(f1)
= sum(precision_s)/len(precision_s)
mean_precision = sum(recall_s)/len(recall_s)
mean_recall = sum(f1_s)/len(f1_s)
mean_f1 = good_s/(good_s+bad_s)
fin_precision = good_s/(good_s+not_found_s)
fin_recall = 2*(fin_precision*fin_recall)/(fin_precision+fin_recall)
fin_f1
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}
= 'full'
an_part
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
```
В какой-то момент может наступить ситуация, когда основные части речи сделаны и дополнены данными словарей. В таком случае остается только нисходяще отсортировать нераспознанные формы, чтобы работать с единицами, которые, согласно распределению Ципфа, сильнее всего увеличивают метрики.
Таинственные случаи, которые целенаправлено оставлены неразобранными в золотом стандарте и не были разобраны анализатором.↩︎