Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати

Вихідний вузол: 2016453

NLP із багатьма мітками стосується завдання присвоєння кількох міток певному введеному тексту, а не лише однієї. У традиційних завданнях НЛП, таких як класифікація тексту або аналіз настроїв, кожному введенню зазвичай присвоюється одна мітка на основі його вмісту. Однак у багатьох сценаріях реального світу фрагмент тексту може належати до кількох категорій або виражати кілька почуттів одночасно.

NLP з декількома мітками важливий, оскільки він дозволяє отримувати більш тонку та складну інформацію з текстових даних. Наприклад, у сфері аналізу відгуків споживачів відгук клієнта може виражати як позитивні, так і негативні настрої одночасно або він може стосуватися кількох аспектів продукту чи послуги. Призначаючи кілька міток для таких вхідних даних, ми можемо отримати більш повне розуміння відгуків клієнтів і вживати більш цілеспрямованих заходів для вирішення їхніх проблем.

У цій статті розглядається заслуговує на увагу випадок використання Provectus НЛП із кількома мітками. 

Контекст: 

До нас звернувся клієнт з проханням допомогти автоматизувати маркування документів певного типу. На перший погляд завдання здавалося простим і легко вирішуваним. Однак, працюючи над цією справою, ми зіткнулися з набором даних із непослідовними анотаціями. Незважаючи на те, що наш клієнт зіткнувся з проблемами, пов’язаними з різними номерами класів і змінами в їхній команді перевірки з часом, вони вклали значні зусилля у створення різноманітного набору даних із низкою анотацій. Хоча в мітках існували певні дисбаланси та невизначеності, цей набір даних надав цінну можливість для аналізу та подальшого дослідження.

 

Давайте детальніше розглянемо набір даних, вивчимо показники та наш підхід, а також нагадаємо, як Provectus вирішив проблему класифікації тексту з кількома мітками.

Набір даних містить 14,354 124 спостереження зі XNUMX унікальними класами (мітками). Наше завдання полягає в тому, щоб кожному спостереженню присвоїти один або декілька класів.

Таблиця 1 надає описову статистику для набору даних.

У середньому ми маємо близько двох класів на одне спостереження, в середньому 261 різних текстів, що описують один клас.

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Таблиця 1: Статистика набору даних
 

На малюнку 1 ми бачимо розподіл класів на верхньому графіку, і ми маємо певну кількість міток HEAD з найвищою частотою появи в наборі даних. Також зауважте, що більшість занять мають низьку частоту зустрічальності.

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
 

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

Ми змінили процес поділу набору даних на набори train/val/test. Замість використання традиційного методу ми застосували ітераційну стратифікацію, щоб забезпечити добре збалансований розподіл доказів зв’язків міток. Для цього ми використовували Мультинавчання Scikit

from skmultilearn.model_selection import iterative_train_test_split mlb = MultiLabelBinarizer() def balanced_split(df, mlb, test_size=0.5): ind = np.expand_dims(np.arange(len(df)), axis=1) mlb.fit_transform(df["tag"]) labels = mlb.transform(df["tag"]) ind_train, _, ind_test, _ = iterative_train_test_split( ind, labels, test_size ) return df.iloc[ind_train[:, 0]], df.iloc[ind_test[:, 0]] df_train, df_tmp = balanced_split(df, test_size=0.4)
df_val, df_test = balanced_split(df_tmp, test_size=0.5)

 

Ми отримали такий розподіл:

  1. Навчальний набір даних містить 60% даних і охоплює всі 124 мітки
  2. Набір даних перевірки містить 20% даних і охоплює всі 124 етикетки
  3. Тестовий набір даних містить 20% даних і охоплює всі 124 етикетки

Класифікація з кількома мітками – це тип керованого алгоритму машинного навчання, який дозволяє нам призначати кілька міток одному зразку даних. Вона відрізняється від бінарної класифікації, де модель передбачає лише дві категорії, і багатокласової класифікації, де модель передбачає лише один із кількох класів для вибірки.

Метрики оцінки ефективності класифікації за багатьма мітками за своєю суттю відрізняються від тих, що використовуються в класифікації за кількома класами (або бінарною) через властиві відмінності проблеми класифікації. Більш детальну інформацію можна знайти у Вікіпедії.

Ми вибрали найбільш підходящі для нас метрики:

  1. Точність вимірює частку справжніх позитивних прогнозів серед загальної кількості позитивних прогнозів, зроблених моделлю.
  2. Згадувати вимірює частку справжніх позитивних прогнозів серед усіх фактичних позитивних зразків.
  3. Оцінка F1 це гармонійне середнє значення точності та відкликання, яке допомагає відновити баланс між ними.
  4. Втрата Хеммінга це частка міток, які неправильно передбачені

Ми також відстежуємо кількість передбачених міток у наборі { визначається як кількість міток, для яких ми отримуємо оцінку F1 > 0}.

Класифікація з кількома мітками – це тип проблеми навчання під наглядом, де один екземпляр або приклад можна пов’язати з кількома мітками чи класифікаціями, на відміну від традиційної класифікації з однією міткою, де кожен екземпляр пов’язується лише з однією міткою класу.

Для вирішення проблем класифікації з кількома мітками існує дві основні категорії методів:

  1. Методи перетворення задач
  2. Методи адаптації алгоритму

Методи перетворення задач дозволяють нам перетворювати завдання класифікації з кількома мітками в завдання класифікації з кількома мітками. Наприклад, базовий підхід двійкової релевантності (BR) розглядає кожну мітку як окрему проблему бінарної класифікації. У цьому випадку проблема з кількома мітками перетворюється на задачу з кількома одномітками.

Методи адаптації алгоритму модифікують самі алгоритми для обробки даних із кількома мітками, не перетворюючи завдання на декілька завдань класифікації з однією міткою. Прикладом такого підходу є модель BERT, яка є попередньо підготовленою мовною моделлю на основі трансформатора, яку можна точно налаштувати для різних завдань NLP, включаючи класифікацію тексту з кількома мітками. BERT розроблено для безпосередньої обробки даних із кількома мітками без необхідності перетворення проблем.

У контексті використання BERT для класифікації тексту з кількома мітками стандартним підходом є використання втрат двійкової крос-ентропії (BCE) як функції втрат. Втрати BCE — це функція втрат, яка зазвичай використовується для задач двійкової класифікації, і її можна легко розширити для вирішення проблем класифікації кількох міток шляхом обчислення втрат для кожної мітки незалежно, а потім підсумовування втрат. У цьому випадку функція втрат BCE вимірює похибку між прогнозованими ймовірностями та істинними мітками, де прогнозовані ймовірності отримані з останнього шару сигмоїдної активації в моделі BERT.

Тепер давайте уважніше розглянемо малюнок 2 нижче. 

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Рисунок 2. Показники для базових моделей
 

На графіку ліворуч показано порівняння показників для «базового рівня: BERT» і «базового рівня: ML». Таким чином, можна побачити, що для «базової лінії: BERT» показники F1 і Recall приблизно в 1.5 рази вищі, тоді як Precision для «базової лінії: ML» у 2 рази вища, ніж у моделі 1. Аналізуючи загальний відсоток передбачених класів, показаних праворуч, ми бачимо, що «базова лінія: BERT» передбачила класи більш ніж у 10 разів більше, ніж «базова лінія: ML».

Оскільки максимальний результат для «базової лінії: BERT» становить менше 50% усіх класів, результати є досить невтішними. Давайте розберемося, як покращити ці результати.

За матеріалами видатної статті «Методи збалансування для класифікації тексту з кількома мітками з розподілом класів за довгим хвостом», ми дізналися, що розподіл збалансованих втрат може бути для нас найбільш прийнятним підходом.

Збалансований розподіл втрат

Збалансована втрата розподілу — це техніка, яка використовується в проблемах класифікації тексту з кількома мітками для усунення дисбалансів у розподілі класів. У цих проблемах деякі класи мають набагато більшу частоту появи порівняно з іншими, що призводить до зміщення моделі в бік цих більш частих класів.

Щоб вирішити цю проблему, розподіл збалансованих втрат має на меті збалансувати внесок кожного зразка у функцію втрат. Це досягається шляхом повторного зважування втрати кожного зразка на основі зворотної частоти його появи в наборі даних. Таким чином, внесок менш частих класів збільшується, а внесок більш частих класів зменшується, таким чином збалансовуючи загальний розподіл класів.

Ця техніка показала свою ефективність у покращенні продуктивності моделей для проблем розподілу класів з довгим хвостом. Зменшуючи вплив частих занять і збільшуючи вплив нечастих занять, модель здатна краще фіксувати закономірності в даних і створювати більш збалансовані прогнози.

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Реалізація класу Resample
 

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np class ResampleLoss(nn.Module): def __init__( self, use_sigmoid=True, partial=False, loss_weight=1.0, reduction="mean", reweight_func=None, weight_norm=None, focal=dict(focal=True, alpha=0.5, gamma=2), map_param=dict(alpha=10.0, beta=0.2, gamma=0.1), CB_loss=dict(CB_beta=0.9, CB_mode="average_w"), logit_reg=dict(neg_scale=5.0, init_bias=0.1), class_freq=None, train_num=None, ): super(ResampleLoss, self).__init__() assert (use_sigmoid is True) or (partial is False) self.use_sigmoid = use_sigmoid self.partial = partial self.loss_weight = loss_weight self.reduction = reduction if self.use_sigmoid: if self.partial: self.cls_criterion = partial_cross_entropy else: self.cls_criterion = binary_cross_entropy else: self.cls_criterion = cross_entropy # reweighting function self.reweight_func = reweight_func # normalization (optional) self.weight_norm = weight_norm # focal loss params self.focal = focal["focal"] self.gamma = focal["gamma"] self.alpha = focal["alpha"] # mapping function params self.map_alpha = map_param["alpha"] self.map_beta = map_param["beta"] self.map_gamma = map_param["gamma"] # CB loss params (optional) self.CB_beta = CB_loss["CB_beta"] self.CB_mode = CB_loss["CB_mode"] self.class_freq = ( torch.from_numpy(np.asarray(class_freq)).float().cuda() ) self.num_classes = self.class_freq.shape[0] self.train_num = train_num # only used to be divided by class_freq # regularization params self.logit_reg = logit_reg self.neg_scale = ( logit_reg["neg_scale"] if "neg_scale" in logit_reg else 1.0 ) init_bias = ( logit_reg["init_bias"] if "init_bias" in logit_reg else 0.0 ) self.init_bias = ( -torch.log(self.train_num / self.class_freq - 1) * init_bias ) self.freq_inv = ( torch.ones(self.class_freq.shape).cuda() / self.class_freq ) self.propotion_inv = self.train_num / self.class_freq def forward( self, cls_score, label, weight=None, avg_factor=None, reduction_override=None, **kwargs ): assert reduction_override in (None, "none", "mean", "sum") reduction = ( reduction_override if reduction_override else self.reduction ) weight = self.reweight_functions(label) cls_score, weight = self.logit_reg_functions( label.float(), cls_score, weight ) if self.focal: logpt = self.cls_criterion( cls_score.clone(), label, weight=None, reduction="none", avg_factor=avg_factor, ) # pt is sigmoid(logit) for pos or sigmoid(-logit) for neg pt = torch.exp(-logpt) wtloss = self.cls_criterion( cls_score, label.float(), weight=weight, reduction="none" ) alpha_t = torch.where(label == 1, self.alpha, 1 - self.alpha) loss = alpha_t * ((1 - pt) ** self.gamma) * wtloss loss = reduce_loss(loss, reduction) else: loss = self.cls_criterion( cls_score, label.float(), weight, reduction=reduction ) loss = self.loss_weight * loss return loss def reweight_functions(self, label): if self.reweight_func is None: return None elif self.reweight_func in ["inv", "sqrt_inv"]: weight = self.RW_weight(label.float()) elif self.reweight_func in "rebalance": weight = self.rebalance_weight(label.float()) elif self.reweight_func in "CB": weight = self.CB_weight(label.float()) else: return None if self.weight_norm is not None: if "by_instance" in self.weight_norm: max_by_instance, _ = torch.max(weight, dim=-1, keepdim=True) weight = weight / max_by_instance elif "by_batch" in self.weight_norm: weight = weight / torch.max(weight) return weight def logit_reg_functions(self, labels, logits, weight=None): if not self.logit_reg: return logits, weight if "init_bias" in self.logit_reg: logits += self.init_bias if "neg_scale" in self.logit_reg: logits = logits * (1 - labels) * self.neg_scale + logits * labels if weight is not None: weight = ( weight / self.neg_scale * (1 - labels) + weight * labels ) return logits, weight def rebalance_weight(self, gt_labels): repeat_rate = torch.sum( gt_labels.float() * self.freq_inv, dim=1, keepdim=True ) pos_weight = ( self.freq_inv.clone().detach().unsqueeze(0) / repeat_rate ) # pos and neg are equally treated weight = ( torch.sigmoid(self.map_beta * (pos_weight - self.map_gamma)) + self.map_alpha ) return weight def CB_weight(self, gt_labels): if "by_class" in self.CB_mode: weight = ( torch.tensor((1 - self.CB_beta)).cuda() / (1 - torch.pow(self.CB_beta, self.class_freq)).cuda() ) elif "average_n" in self.CB_mode: avg_n = torch.sum( gt_labels * self.class_freq, dim=1, keepdim=True ) / torch.sum(gt_labels, dim=1, keepdim=True) weight = ( torch.tensor((1 - self.CB_beta)).cuda() / (1 - torch.pow(self.CB_beta, avg_n)).cuda() ) elif "average_w" in self.CB_mode: weight_ = ( torch.tensor((1 - self.CB_beta)).cuda() / (1 - torch.pow(self.CB_beta, self.class_freq)).cuda() ) weight = torch.sum( gt_labels * weight_, dim=1, keepdim=True ) / torch.sum(gt_labels, dim=1, keepdim=True) elif "min_n" in self.CB_mode: min_n, _ = torch.min( gt_labels * self.class_freq + (1 - gt_labels) * 100000, dim=1, keepdim=True, ) weight = ( torch.tensor((1 - self.CB_beta)).cuda() / (1 - torch.pow(self.CB_beta, min_n)).cuda() ) else: raise NameError return weight def RW_weight(self, gt_labels, by_class=True): if "sqrt" in self.reweight_func: weight = torch.sqrt(self.propotion_inv) else: weight = self.propotion_inv if not by_class: sum_ = torch.sum(weight * gt_labels, dim=1, keepdim=True) weight = sum_ / torch.sum(gt_labels, dim=1, keepdim=True) return weight def reduce_loss(loss, reduction): """Reduce loss as specified. Args: loss (Tensor): Elementwise loss tensor. reduction (str): Options are "none", "mean" and "sum". Return: Tensor: Reduced loss tensor. """ reduction_enum = F._Reduction.get_enum(reduction) # none: 0, elementwise_mean:1, sum: 2 if reduction_enum == 0: return loss elif reduction_enum == 1: return loss.mean() elif reduction_enum == 2: return loss.sum() def weight_reduce_loss(loss, weight=None, reduction="mean", avg_factor=None): """Apply element-wise weight and reduce loss. Args: loss (Tensor): Element-wise loss. weight (Tensor): Element-wise weights. reduction (str): Same as built-in losses of PyTorch. avg_factor (float): Avarage factor when computing the mean of losses. Returns: Tensor: Processed loss values. """ # if weight is specified, apply element-wise weight if weight is not None: loss = loss * weight # if avg_factor is not specified, just reduce the loss if avg_factor is None: loss = reduce_loss(loss, reduction) else: # if reduction is mean, then average the loss by avg_factor if reduction == "mean": loss = loss.sum() / avg_factor # if reduction is 'none', then do nothing, otherwise raise an error elif reduction != "none": raise ValueError( 'avg_factor can not be used with reduction="sum"' ) return loss def binary_cross_entropy( pred, label, weight=None, reduction="mean", avg_factor=None
): # weighted element-wise losses if weight is not None: weight = weight.float() loss = F.binary_cross_entropy_with_logits( pred, label.float(), weight, reduction="none" ) loss = weight_reduce_loss( loss, reduction=reduction, avg_factor=avg_factor ) return loss

DBLoss
 

loss_func = ResampleLoss( reweight_func="rebalance", loss_weight=1.0, focal=dict(focal=True, alpha=0.5, gamma=2), logit_reg=dict(init_bias=0.05, neg_scale=2.0), map_param=dict(alpha=0.1, beta=10.0, gamma=0.405), class_freq=class_freq, train_num=train_num,
) """
class_freq - list of frequencies for each class,
train_num - size of train dataset """

 

Уважно дослідивши набір даних, ми дійшли висновку, що параметр Рівняння

Рівняння = 0.405.

Порогова настройка

Ще одним кроком у вдосконаленні нашої моделі став процес налаштування порогового значення як на етапі навчання, так і на етапах валідації та тестування. Ми розрахували залежність таких показників, як f1-бал, точність і запам’ятовування, від порогового рівня, і ми вибрали поріг на основі найвищого показника метрики. Нижче ви можете побачити реалізацію функції цього процесу.

Оптимізація оцінки F1 шляхом налаштування порогу:

def optimise_f1_score(true_labels: np.ndarray, pred_labels: np.ndarray): best_med_th = 0.5 true_bools = [tl == 1 for tl in true_labels] micro_thresholds = (np.array(range(-45, 15)) / 100) + best_med_th f1_results, pre_results, recall_results = [], [], [] for th in micro_thresholds: pred_bools = [pl > th for pl in pred_labels] test_f1 = f1_score(true_bools, pred_bools, average="micro", zero_division=0) test_precision = precision_score( true_bools, pred_bools, average="micro", zero_division=0 ) test_recall = recall_score( true_bools, pred_bools, average="micro", zero_division=0 ) f1_results.append(test_f1) prec_results.append(test_precision) recall_results.append(test_recall) best_f1_idx = np.argmax(f1_results) return micro_thresholds[best_f1_idx]

Оцінка та порівняння з вихідним рівнем

Ці підходи дозволили нам навчити нову модель і отримати такий результат, який порівнюється з базовою лінією: BERT на малюнку 3 нижче.

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Рисунок 3. Показники порівняння за базовим сценарієм і новішим підходом.
 

Порівнюючи метрики, релевантні для класифікації, ми бачимо значне збільшення показників ефективності майже в 5-6 разів:

Оцінка F1 зросла з 12% → 55%, тоді як точність зросла з 9% → 59%, а запам'ятовування зросла з 15% → 51%.

Завдяки змінам, показаним на правому графіку на малюнку 3, тепер ми можемо передбачити 80% класів.

Зрізи класів

Ми розділили наші мітки на чотири групи: HEAD, MEDIUM, TAIL і ZERO. Кожна група містить мітки з однаковою кількістю допоміжних даних спостережень.

Як видно на малюнку 4, розподіли груп відрізняються. Трояндова коробка (HEAD) має негативний перекіс розподілу, середня коробка (MEDIUM) має позитивний перекіс розподілу, а зелена коробка (TAIL), здається, має нормальний розподіл.

Усі групи також мають викиди, які є точками за межами вусів на прямокутному графіку. Група HEAD має великий вплив на клас MAJOR.

Крім того, ми визначили окрему групу під назвою «ZERO», яка містить мітки, які модель не змогла вивчити та не може розпізнати через мінімальну кількість випадків у наборі даних (менше 3% усіх спостережень).

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Рисунок 4. Кількість міток у порівнянні з групами
 

Таблиця 2 містить інформацію про показники для кожної групи міток для тестової підмножини даних.

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Таблиця 2. Показники по групах.
 

  1. Група HEAD містить 21 мітку із середнім показником 112 допоміжних спостережень на мітку. На цю групу впливають викиди, і завдяки високій представленості в наборі даних її показники високі: F1 – 73%, точність – 71%, запам’ятовування – 75%.
  2. Група MEDIUM складається з 44 міток із середньою підтримкою 67 спостережень, що приблизно вдвічі менше, ніж група HEAD. Очікується, що показники цієї групи зменшаться на 50%: F1 – 52%, Precision – 56%, Recall – 51%.
  3. Група TAIL має найбільшу кількість класів, але всі вони погано представлені в наборі даних, у середньому 40 допоміжних спостережень на мітку. В результаті показники значно падають: F1 – 21%, Precision – 32%, Recall – 16%.
  4. Група ZERO включає класи, які модель не може розпізнати взагалі, потенційно через їх низьку кількість у наборі даних. Кожна з 24 міток у цій групі має в середньому 7 допоміжних спостережень.

Рисунок 5 візуалізує інформацію, представлену в таблиці 2, забезпечуючи візуальне представлення показників для кожної групи міток.

 

Багатозначний НЛП: Аналіз класового дисбалансу та підходи до функції втрати
Рисунок 5. Метрики та групи міток. Усі значення НУЛЬ = 0.

У цій вичерпній статті ми продемонстрували, що, здавалося б, просте завдання класифікації тексту з кількома мітками може бути складним, якщо застосовувати традиційні методи. Ми запропонували використовувати функції розподілу-балансування втрат для вирішення проблеми дисбалансу класів.

Ми порівняли ефективність запропонованого нами підходу з класичним методом і оцінили його за допомогою реальних бізнес-метрик. Результати демонструють, що використання функцій втрат для усунення дисбалансу класів і спільної появи міток пропонує життєздатне рішення для класифікації тексту з кількома мітками.

Запропонований варіант використання підкреслює важливість розгляду різних підходів і методів під час роботи з класифікацією тексту з кількома мітками, а також потенційні переваги розподілу-балансування функцій втрат у вирішенні дисбалансу класів.

Якщо ви зіткнулися з подібною проблемою і прагнете упорядкувати операції з обробки документів у вашій організації, зв’яжіться зі мною або командою Provectus. Ми з радістю допоможемо вам знайти більш ефективні методи автоматизації ваших процесів.

 
 
Олексій Бабич є інженером машинного навчання в Provectus. Маючи досвід у галузі фізики, він володіє відмінними аналітичними та математичними здібностями, а також отримав цінний досвід під час наукових досліджень і виступів на міжнародних конференціях, зокрема SPIE Photonics West. Олексій спеціалізується на створенні наскрізних великомасштабних рішень AI/ML для галузі охорони здоров’я та фінтех. Він бере участь у кожному етапі життєвого циклу розробки ML, від визначення бізнес-проблем до розгортання та запуску виробничих моделей ML.
 

Рінат Ахметов є архітектором рішень ML у Provectus. Маючи солідний практичний досвід у сфері машинного навчання (особливо комп’ютерного бачення), Рінат є ботаніком, ентузіастом даних, програмістом і трудоголіком, другою найбільшою пристрастю якого є програмування. У Provectus Рінат відповідає за фази відкриття та підтвердження концепції, а також керує виконанням складних проектів ШІ.
 

Часова мітка:

Більше від KDnuggets