×Закрыть

Перші кроки в NLP: розглядаємо Python-бібліотеку NLTK в реальному завданні

Усім привіт! Звати мене Андрій, і я 8 років працюю в оцінці майна, в жодній ІТ-компанії не працював, але люблю програмувати. Як хобі я почав готувати власний проєкт — систему для зручного й ефективного відображення оголошень з продажу нерухомості в Україні з різноманітних загальнодоступних джерел. У результаті проєкт «виріс» до доволі великих розмірів і включає систему збору й попередньої обробки оголошень, систему знаходження їхніх геокоординат, систему класифікації оголошень за певними типами й невеликий користувацький інтерфейс, написаний на Django. А в цій статті я спробую детально розповісти про реалізацію однієї з його частин, а саме класифікацію оголошень з продажу земельних ділянок в Україні за допомогою методів NLP.

Спочатку під час роботи над проєктом я поставив перед собою ціль ознайомитися з різноманітними бібліотеками Python на реальному завданні. Мета написання статті — поділитися набутим досвідом, враховуючи, що в українськомовному сегменті мережі таких статей, на мій погляд, недостатньо. У результаті вийшов огляд можливостей кількох бібліотек Python для машинного навчання, а саму статтю довелося розділити на три частини. Я наведу приклади реалізації класифікаторів, написаних за допомогою бібліотек NLTK, scikit-learn і TensorFlow.

Перша частина статті включає перше знайомство з даними й приклад побудови класифікаторів на основі бібліотеки NLTK.

Друга частина міститиме приклад побудови класифікаторів на основі бібліотеки scikit-learn.

А третя частина охопить кілька прикладів нейронних мереж, побудованих з використанням бібліотеки TensorFlow.

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

Отже, починаймо.

Постановка проблеми й знайомство з даними

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

В Україні земельні ділянки поділяють за цільовим призначенням на 119 видів. Мені для роботи така детальна класифікація не потрібна, тому я виділив 7 типів (класів) земельних ділянок. Ось вони:

Таблиця 1. Типи земельних ділянок

IDТип ділянкиКоротка назва
1Для будівництва й обслуговування житлового будинку, господарських будівель і споруд (присадибна ділянка)ОЖБ
2Для ведення особистого селянського господарства й подібніОСГ
3Для індивідуального й колективного садівництва та дачного будівництваCадова
4Під комерційну діяльність та інші, що не ввійшли до решти групКомерційна
5Під багатоквартирний будинокБагатоповерхова
6Для ведення товарного сільського господарства й подібніТоварна
7Одна з ділянок під ОЖБ, а одна під ОСГОЖБ й ОСГ

В оголошеннях можуть продавати як одну, так і кілька земельних ділянок з різним цільовим призначення.

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

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

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

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

Для вирішення поставлених задач потрібно було підготувати певну кількість уже класифікованих (помічених) оголошень, котрі можна було б передати в ту чи іншу систему машинного навчання. Я сам проводив розмітку даних (і за певний час перевірку). Загальна кількість підібраних оголошень становить 4256 екземплярів. Нині, звичайно, така кількість екземплярів мізерна (або навіть сміховинна), але тут потрібно пам’ятати, що ми говоримо про оголошення з продажу земельних ділянок — вузькоспеціалізовану галузь, де визначити тип ділянки можна за обмеженою кількістю слів.

Погляньмо на перші десять оголошень, що я підібрав (весь код тут):

Таблиця 2. Перші оголошення з набору

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

Детальніше розглянемо кожну колонку.

«land_types_source» — тип ділянки визначено або невизначено в джерелі інформації.

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

«description» — поле з описом оголошення. З цього поля видалено всі цифри, номери, розділові знаки тощо. Залишено лише слова (букви), розділені пробілом. Інформація з поля «description» — лише результат роботи програми, що я розробив (зразок наведу нижче).

«built_up» — поле, що показує: ділянку забудовано (цифра «1») чи незабудовано (цифра «0»).

«land_types» — поле, що показує тип (клас) земельних ділянок в оголошенні (id типів наведено в таблиці 1).

«land_squer» — показує площу ділянок в оголошенні.

З даними ми ознайомилися. Погляньмо на розподіл оголошень за класами:

Рис. 1. Розподіл оголошень за класами

Як бачимо, у датасеті є дисбаланс між класами. Оголошення з продажу забудованих земельних ділянок становлять лише невелику частину всіх наявних оголошень, а серед типів земельних ділянок ситуація ще гірша: земельні ділянки під житлове будівництво й садові ділянки становлять 82,78% наявних оголошень, а оголошень з продажу земельних ділянок для ведення товарного сільського господарства лише п’ять.

Тут потрібно звернути увагу на кілька особливостей набору даних, що я підготував. Передусім оголошення з продажу земельних ділянок для ведення товарного сільського господарства є викидами. В Україні станом на дату підготовки статті діє мораторій на продаж земельних ділянок для ведення товарного сільського господарства (до речі, на відміну від земельних ділянок, наданих для ведення особистого селянського господарства). Однак є велика ймовірність, що мораторій знімуть, тому я вирішив залишити ці оголошення у вибірці. По-друге, у вибірці є лише один комбінований тип земельних ділянок: під ОЖБ й ОСГ, інші комбінації відкинуто як викиди, оскільки трапляються дуже й дуже рідко. І останнє — кожний запис у вибірці унікальний за комбінацією стовпців «land_types_source», «land_types_PcmU» й «description», а самі оголошення можуть повторюватися.

Для спрощення процесу класифікації, я об’єднав стовпці «land_types_source», «land_types_PcmU» й «description» в один стовпець «text».

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

NLP і модель «мішка слів»

Отже, обробка природної мови (Natural-languageprocessing, NLP) — загальний напрям інформатики, штучного інтелекту й математичної лінгвістики. Він вивчає проблеми комп’ютерного аналізу й синтезу природної мови. Щодо штучного інтелекту, то аналіз — це розуміння мови, а синтез — генерація розумного тексту.

Методи NLP дозволяють вирішувати різноманітні завдання: від класифікації тональності текстів, машинного перекладу, спам-фільтрів, розмітки частин мови до генерації тексту.

У своєму проєкті я використав одну з моделей NLP — модель «мішка слів» (МС, bag-of-words). Модель МС дає змогу презентувати текст як невпорядковану колекцію слів (якщо бути точним, то токенів) без урахування граматичних правил і порядку розміщення їх у конкретному розділі тексту.

В основі моделі «мішка слів» лежить досить проста ідея, яку можна резюмувати так:

  1. Створити з усього набору документів вокабуляр унікальних лексем (токенів) — наприклад, слів. Якщо послідовність елементів (токенів) у моделі МС складається зі слів, вона називається юніграмною (наприклад, речення «Продаж земельної ділянки» ділять на елементи [«продаж», «земельної», «ділянки»]). Але вона може складатися й з послідовності слів (наприклад, двограмної послідовності [«продаж земельної», «земельної ділянки»]). У ширшому значенні в NLP безперервні послідовності елементів — слова, літери або символи — називають також n-грамами. Вибір числа n у n-грамній моделі залежить від окремо взятої ділянки.
  2. Побудувати з кожного документа вектор ознак, що містить частотності входжень кожного слова в певний документ.

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

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

Бібліотека NLTK

Natural Language Toolkit (NLTK) — це набір бібліотек і програм для символьної та статистичної обробки природних мов, написаної мовою програмування Python. Її розробили Стівен Берд й Едвард Лопер.

NLTK визначає інфраструктуру, яку можна використати для побудови програм NLP у Python. Вона надає базові класи для представлення даних, що мають відношення до обробки природної мови; стандартні інтерфейси для виконання таких завдань: анотування частин мови, синтаксичний розбір і класифікація тексту; і стандартні реалізації для кожного завдання, які можуть бути об’єднані для вирішення складних завдань.

Для початку роботи з NLTK необхідно згадати ще кілька понять:

  • Tokenizer — забезпечує токенізацію документів (розбиття на лексеми);
  • Stopwords — слова, які зустрічаються в мові надто часто, щоб нести якусь важливу інформацію (наприклад, артикль «а»);
  • Stemming (стемінг) — це процес скорочення слова до основи шляхом відкидання допоміжних частин, таких як закінчення або суфікса (наприклад, слова «будинку», «будинки» рахуватимуть як одне слово «будинк»);
  • Lemmatisation (лематизація) — це процес групування переплетених форм слова, щоб їх можна було проаналізувати як один предмет, ідентифікований лемою слова або словниковою формою (наприклад, слова «будинку», «будинки» рахуватимуть як одне слово «будинок»).

Я вирішив написати про спосіб реалізації моделі «мішка слів» на NLTK, враховуючи, що ця реалізація допоможе краще зрозуміти модель і підводні камені, які з нею пов’язані.

Отримавши всю необхідну інформацію, переходьмо безпосередньо до коду.

Перш за все визначимо Tokenizer, який буде проводити попередню обробку тексту й розбивку його на окремі слова (не забуваючи, звичайно, про імпорт відповідних бібліотек):

def ua_tokenizer(text,ua_stemmer=True,stop_words=[]):
    """ Tokenizer for Ukrainian language, returns only alphabetic tokens.
    
    Keyword arguments:
    text -- text for tokenize
    ua_stemmer -- if True use UkrainianStemmer for stemming words (default True)
    stop_words -- list of stop words (default [])
    """
    tokenized_list=[]
    text=re.sub(r"""['’"`�]""", '', text)
    text=re.sub(r"""([0-9])([\u0400-\u04FF]|[A-z])""", r"\1 \2", text)
    text=re.sub(r"""([\u0400-\u04FF]|[A-z])([0-9])""", r"\1 \2", text)
    text=re.sub(r"""[\-.,:+*/_]""", ' ', text)

    for word in nltk.word_tokenize(text):
        if word.isalpha():
            word=word.lower()
        if ua_stemmer is True:
            word=UkrainianStemmer(word).stem_word()
        if word not in stop_words:    
            tokenized_list.append(word)
    return tokenized_list

Функція ua_tokenizer отримує на вході текст, який потрібно розбити на токени, список stop_words, якщо треба також підтвердження в необхідності використовувати стемер. На початковому етапі очищає текст від додаткових символів, далі розбиває його на токени за допомогою функції nltk.word_tokenize(), потім перевіряє, чи токен складається зі слів (метод isalpha()), якщо так, то слова приводять до нижнього регістра, за потреби обробляють стемером і записують у новий список.

Спочатку я просто розбивав текст за допомогою функції word_tokenize, без початкового очищення, але був здивований результатами: як виявилося, метод isalpha() повертає False, якщо токен містить будь-який не алфавітний символ, тож слово «дерев’яний» у цьому варіанті відкидається, а слово ’дерев«яний’ розділяється на два ’дерев’ та ’яний’. Виходить, невідомо що, тому важливо перед подачею будь-якого тексту у word_tokenize попередньо його обробити й очистити. У моєму датасеті всі дані вже очищено.

Маючи токенайзер, отримаємо докладнішу інформацію про нашу вибірку:

def  ngrams_info(series,n=1,most_common=20,ua_stemmer=True,stop_words=[]):
    """ ngrams_info - Show detailed information about string pandas.Series column.
    
    Keyword arguments:
    series -- pandas.Series object
    most_common -- show most common words(default 50)
    ua_stemmer -- if True use UkrainianStemmer for stemming words (default True)
    stop_words -- list of stop words (default [])
   	 
    """
    words=series.str.cat(sep=' ')
    print ('Кількість символів: ',len(words))
    words=nltk.ngrams(ua_tokenizer(words,ua_stemmer=ua_stemmer,stop_words=stop_words),n)
    words=nltk.FreqDist(words)
    print ('Кількість токенів: ',words.N())
    print ('Кількість унікальних токенів: ',words.B())
    print ('Найбільш уживані токени: ',words.most_common(most_common))
    words.plot (most_common, cumulative = True)

Функція ngrams_info об’єднує всі рядки об’єкта pandas.Series в один об’єкт типу string, далі за допомогою функції nltk.ngrams утворює список n-грамів (токенів) вказаного розміру, а функція nltk.FreqDist створює розподіл частот, що містить дані про n-грами й друкує основну інформацію.

Подивимось, що ми отримаємо, якщо виконаємо цю функцію з кількома різними параметрами.

Для юніграмів:

Рис. 2. Частоти найуживаніших юніграмів

Для триграмів:

Рис. 3. Частоти найуживаніших триграмів

Отже, для юніграмів (читай — слів) ми маємо у вибірці з 4256 екземплярів 151 318 токенів, з яких лише 6638 токенів (під час використання стемера) унікальні. А на частку 20 найуживаніших токенів припадає близько 30% усіх токенів, що є в датасеті.

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

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

А зараз перейдемо нарешті до класифікації.

Як моделі для класифікації я використав три класи класифікаторів nltk.NaiveBayesClassifier, nltk.MaxentClassifier і nltk.DecisionTreeClassifier. До технічної частини я не вдаватимуся, але коротко розповім про класифікатори. Кому цікаво, є книжка про бібліотеку NLTK — Natural Language Processing with Python (автори Steven Bird, Ewan Klein and Edward Loper), в якій детально розглянуто можливості бібліотеки загалом і специфіку класифікаторів (враховуючи, що її написали автори бібліотеки, я розглядаю її як «частину/доповнення» до документації). Під час написання коду до цієї статті в частині бібліотеки NLTK за основу я брав приклади саме з цієї книжки.

Отже, клас nltk.DecisionTreeClassifier базується на алгоритмі дерева ухвалення рішень. Суть моделі полягає в розбитті даних на підмножини шляхом ухвалення рішень, що ґрунтуються на відповідях за серією питань. Використовуючи алгоритм, ми починаємо в корені дерева й розщеплюємо дані за певними атрибутами. Є різні способи вибирати черговий атрибут, наприклад за критерієм найбільшого приросту інформації (вимірює, наскільки організованішими стають вхідні значення, коли ми поділяємо їх, використовуючи цей атрибут). Далі ми повторюємо процедуру розщеплення ітеративно в кожному дочірньому вузлі, поки не отримаємо однорідних листів. Тобто всі зразки в кожному вузлі належатимуть до того ж самого класу.

Клас nltk.NaiveBayesClassifier базується на наївному класифікаторі Баєса (НКБ). Наївні класифікатори Баєса — це сімейство простих імовірнісних класифікаторів, що ґрунтуються на застосуванні теореми Баєса з припущенням (наївним) про незалежність між змінними. Суть НКБ полягає в тому, що нам треба знайти такий клас, при якому його ймовірність для конкретного випробування була б максимальною, а припущення про незалежність між змінними потрібне для спрощення розрахунків.

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

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

Важливо знати: класи класифікаторів, які базуються на певних алгоритмах, реалізовано в кожній бібліотеці за своєю логікою й математичним обґрунтуванням, і вони можуть відрізнятися від «класичних» інтерпретацій, даючи дещо інші результати. На це треба зважати!

Далі для навчання класифікатора, будь-якого з наведених класифікаторів, використовується метод train, що першим параметром приймає параметр labeled_featuresets (з документації «param labeled_featuresets: A list of classified featuresets, i.e., a list of tuples ``(featureset, label)``»). За своєю суттю параметр labeled_featuresets — це список, кожен елемент якого містить кортеж, що презентує одне випробування (наприклад, одне оголошення з набору) й складається з двох елементів: перший featureset — це ще один список, кожен елемент якого містить словник виду: {токен : наявність або відсутність токена у випробуванні (True або False)}, довжина списку featureset для всіх випробувань однакова й визначається вокабуляром унікальних токенів, другий елемент кортежу — це мітка класу для випробування. На перший погляд це здається складним (а якщо розібратись то й неефективним в обчислювальному плані), але спробую по порядку розповісти, як це можна реалізувати. Насамперед, визначимо вокабуляр (читай — список) унікальних токенів:

words=dataframe[X_column].str.cat(sep=' ') words=nltk.ngrams(ua_tokenizer(words,ua_stemmer=ua_stemmer,stop_words=stop_words),n=n)
words=nltk.FreqDist(words)
word_features=words.most_common(most_common)
word_features=[words[0] for words in word_features]

Тут усе просто: за аналогією до функції ngrams_info всі значення з колонки об’єкту DataFrame об’єднуємо в один об’єкт типу string, перетворюємо в об’єкт FreqDist (див. пояснення вище), знаходимо перших most_common-токенів, що найчастіше з’являються з їхніми частотами, і складаємо список найчастіше вживаних токенів без їхніх частот. Основне завдання цього етапу — знайти список перших найуживаніших токенів (юніграмів, біграмів тощо), що трапляються в усьому наборі даних. Чому беремо найбільш вживані токени? Тут ми керуємося припущенням, що найуживаніші токени несуть найбільше інформації, а решту можна відкинути. Це припущення має очевидні недоліки, але поки що візьмімо його за основу.

На наступному етапі визначимо невелику функцію, котра буде будувати featureset. Ось вона:

def bag_of_words(document_tokens,word_features):
    """ Return the dict of bag_of_words.

    Keyword arguments:
    document_tokens -- list of tokens
    word_features -- list of features

    """
   	 
    features={}
    for word in word_features:
        features['contains({})'.format(word)]=(word[0] in document_tokens)   	 
    return features

Функція bag_of_words на вході приймає список токенів кожного випробування document_tokens (у нашому випадку оголошення) і список найуживаніших токенів усієї вибірки word_features, повертає словник, що містить назву токена та його наявність або відсутність у конкретному випробуванні.

Нам ще залишається побудувати список токенів кожного випробування, але це не проблема, бо ми вже це вміємо робити.

Отже, нижче наведено універсальну функцію для базових класифікаторів бібліотеки NLTK:

def nltk_classifiers(dataframe,X_column,y_column,classifier=nltk.NaiveBayesClassifier,n=1,stop_words=[],ua_stemmer=False,most_common=1000):
     
    words=dataframe[X_column].str.cat(sep=' ')
    words=nltk.ngrams(ua_tokenizer(words,ua_stemmer=ua_stemmer,stop_words=stop_words),n=n)
    words=nltk.FreqDist(words)
    word_features=words.most_common(most_common)
    word_features=[words[0] for words in word_features]
    
    labeled_featuresets=[]
    for _,row in dataframe.iterrows():
        row[X_column]=nltk.ngrams(ua_tokenizer(row[X_column],ua_stemmer=ua_stemmer,stop_words=stop_words),n=n)
        row[X_column]=[words[0] for words in nltk.FreqDist(row[X_column])]   	 
        labeled_featuresets.append((bag_of_words(row[X_column],word_features=word_features), row[y_column]))  
   	 
    
    train_set,test_set,_,_=train_test_split(labeled_featuresets,dataframe[y_column],stratify=dataframe[y_column],test_size=0.33)
    
    if classifier==nltk.MaxentClassifier:
        classifier=classifier.train(train_set, max_iter=5)
    else:
        classifier=classifier.train(train_set)    	 
        accuracy_train=nltk.classify.accuracy(classifier, train_set)
        accuracy=nltk.classify.accuracy(classifier, test_set)
        print('Точність класифікатора на навчальних даних:',accuracy_train)
        print('Точність класифікатора на тестових даних:',accuracy)
        y_true=[]
        y_pred=[]
        for test in test_set:
            y_true.append(test[1])
            y_pred.append(classifier.classify(test[0]))
        confmat=nltk.ConfusionMatrix(y_pred,y_true)
        print(confmat)
    return classifier  

На початковому етапі ми знаходимо найуживаніші токени, далі в циклі for перебираємо кожен елемент вибірки й перетворюємо текст випробування в список токенів, а далі створюємо список labeled_featuresets.

Ми перетворили нашу вибірку у формат, придатний для подачі до класифікатора, однак постає питання якісного оцінювання класифікатора. Зрозуміло, що якщо ми будемо оцінювати класифікатори за даними, за якими вони навчалися, не відомо, як вони будуть поводити себе на даних, яких вони не бачили під час навчання. Найпростіший вихід із ситуації — розбиття вибірки на дві підмножини: навчальну й тестову, в ідеалі тестову множину можна використовувати лише для фінальної перевірки класифікатора з уже підібраними параметрами. Функція train_test_split бібліотеки scikit-learn розбиває нашу множину вибірки на дві підмножини: навчальну й тестову, пропорційно попередньо визначеним класам (за це відповідає параметр stratify), у такому разі вона має дивний вигляд, але це пов’язано з різною логікою подачі даних в обох бібліотеках (про бібліотеку scikit-learn поговоримо в другій частині статті, тому зациклюватися на ній не буду).

На наступному етапі після розбиття даних навчаємо наш класифікатор.

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

У нас усе готово, тож запускаємо класифікатори з кількома різними параметрами для порівняння. Код нижче:

classifiers=[nltk.NaiveBayesClassifier,nltk.MaxentClassifier,nltk.DecisionTreeClassifier]
for y_column in ('land_types','built_up'):
    for classifier in classifiers:    
        for n in (1,3): 	 
            print ('Класифікатор -',classifier)
            print ('Порядок n -',n)      	 
            print ('Класифікатор за колонкою -',y_column)
            model=nltk_classifiers(land_data,X_column='text',y_column=y_column,classifier=classifier, n=n)
            if classifier==nltk.NaiveBayesClassifier:
                print ('Найважливіші токени для класифікації за колонкою -',y_column)
                model.show_most_informative_features(10)

На мій погляд, тут майже все зрозуміло, тож не затримуватимуся. Єдиний момент — метод show_most_informative_features() класифікатора nltk.NaiveBayesClassifier показує зазначену кількість токенів, що найкраще класифікують той чи інший об’єкт.

А тепер перейдемо до результатів класифікації за типами для юніграмів для кожного класифікатора:

Результати класифікації за типами для класифікатора nltk.NaiveBayesClassifier:

Класифікатор - <class 'nltk.classify.naivebayes.NaiveBayesClassifier'>
Порядок n - 1
Класифікатор за колонкою - land_types
Точність класифікатора на навчальних даних: 0.8796913363732024
Точність класифікатора на тестових даних: 0.8327402135231317
  |   1   2   3   4   5   6   7 |
--+-----------------------------+
1 |<735>  4   5   7   8   .   9 |
2 |   5 <86>  7   8   .   2   3 |
3 |  76  15<271>  5   1   .   . |
4 |  14   5   2 <61>  1   .   . |
5 |   7   .   .   2  <3>  .   . |
6 |   .   .   .   .   .  <.>  . |
7 |  36   7   5   1   .   . <14>|
--+-----------------------------+
(row = reference; col = test)

Результати класифікації за типами для класифікатора nltk.MaxentClassifier:

Класифікатор - <class 'nltk.classify.maxent.MaxentClassifier'>
Порядок n - 1
Класифікатор за колонкою - land_types
  ==> Training (5 iterations)

  	Iteration	Log Likelihood	Accuracy
  	---------------------------------------
         	1      	-1.94591    	0.019
         	2      	-1.07311    	0.697
         	3      	-0.83080    	0.697
         	4      	-0.71131    	0.697
     	Final      	-0.64350    	0.697
Точність класифікатора на навчальних даних: 0.6969484391441599
Точність класифікатора на тестових даних: 0.6832740213523132
  |   1   2   3   4   5   6   7 |
--+-----------------------------+
1 |<869>113 216  70  13   2  26 |
2 |   .  <4>  .   1   .   .   . |
3 |   3   . <74>  .   .   .   . |
4 |   1   .   . <13>  .   .   . |
5 |   .   .   .   .  <.>  .   . |
6 |   .   .   .   .   .  <.>  . |
7 |   .   .   .   .   .   .  <.>|
--+-----------------------------+
(row = reference; col = test)

Результати класифікації за типами для класифікатора nltk.DecisionTreeClassifier:

Класифікатор - <class 'nltk.classify.decisiontree.DecisionTreeClassifier'>
Порядок n - 1
Класифікатор за колонкою - land_types
Точність класифікатора на навчальних даних: 0.9786039985969835
Точність класифікатора на тестових даних: 0.9138790035587189
  |   1   2   3   4   5   6   7 |
--+-----------------------------+
1 |<827>  4  17  22   2   .   2 |
2 |   7<106>  2   3   .   .  10 |
3 |  14   2<271>  .   .   .   3 |
4 |  18   2   . <58>  1   .   1 |
5 |   6   .   .   1 <10>  .   . |
6 |   .   .   .   .   .  <2>  . |
7 |   1   3   .   .   .   . <10>|
--+-----------------------------+
(row = reference; col = test)

Отже, найкращі результати класифікації має класифікатор nltk.DecisionTreeClassifier. Тепер подивимось на результати класифікації за забудованістю.

Результати класифікації за забудованістю для класифікатора nltk.NaiveBayesClassifier:

Класифікатор - <class 'nltk.classify.naivebayes.NaiveBayesClassifier'>
Порядок n - 1
Класифікатор за колонкою - built_up
Точність класифікатора на навчальних даних: 0.9214310768151526
Точність класифікатора на тестових даних: 0.892526690391459
  |   0	    1 |
--+-----------+
0 |<1096>  45 |
1 |  106 <158>|
--+-----------+
(row = reference; col = test)

Результати класифікації за забудованістю для класифікатора nltk.MaxentClassifier:

Класифікатор - <class 'nltk.classify.maxent.MaxentClassifier'>
Порядок n - 1
Класифікатор за колонкою - built_up
  ==> Training (5 iterations)

      Iteration    Log Likelihood    Accuracy
      ---------------------------------------
             1          -0.69315        0.145
             2          -0.57463        0.884
             3          -0.50751        0.884
             4          -0.46603        0.884
         Final          -0.43991        0.884
Точність класифікатора на навчальних даних: 0.8842511399508944
Точність класифікатора на тестових даних: 0.8725978647686833
  |    0    1 |
--+-----------+
0 |<1196> 173 |
1 |    6  <30>|
--+-----------+
(row = reference; col = test)

Результати класифікації за забудованістю для класифікатора nltk.DecisionTreeClassifier:

Класифікатор - <class 'nltk.classify.decisiontree.DecisionTreeClassifier'>
Порядок n - 1
Класифікатор за колонкою - built_up
Точність класифікатора на навчальних даних: 0.9705366538056822
Точність класифікатора на тестових даних: 0.9373665480427046
  |   0	    1 |
--+-----------+
0 |<1155>  41 |
1 |   47 <162>|
--+-----------+
(row = reference; col = test)

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

Важливо зауважити, що для забудованих земельних ділянок класифікатор, що присвоюватиме всім значенням вибірки значення 0 (фактично класифікатором не буде, а прийматиме, що всі земельні ділянки незабудовані й не розв’язуватиме поставленого завдання) точність становитиме 85,53% (частка незабудованих земельних ділянок у вибірці), тому всі класифікатори нижче цього рівня можна навіть не розглядати.

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

Наприклад, умовно у вибірці є 50 оголошень продажу вільних від забудови ділянок у селі Сокільники, тоді класифікатор розглядатиме токен «сокільник» як ідентифікатор класу «незабудована», що за своєю суттю неправильно. Але як зрозуміти, як кожен токен «сприймається» класифікатором? Однією з особливостей бібліотеки NLTK є можливість виклику методу show_most_informative_features() для класифікаторів nltk.NaiveBayesClassifier і nltk.MaxentClassifier. Ось результати для класифікатора nltk.NaiveBayesClassifier:

Для класифікації за типами ділянок:

Найважливіші токени для класифікації за колонкою - land_types
Most Informative Features
contains(('виробництв',)) = True            6 : 1  	=   620.9 : 1.0
contains(('сільськогосподарськ',)) = True   6 : 1  	=   443.5 : 1.0
contains(('селянськ',)) = True              2 : 1  	=   309.9 : 1.0
contains(('веденн',)) = True                6 : 1  	=   282.2 : 1.0
contains(('me',)) = True                    6 : 1  	=   266.1 : 1.0
contains(('канал',)) = True                 6 : 1  	=   266.1 : 1.0
contains(('sitalozemlyaзагальн',)) = True   6 : 1  	=   266.1 : 1.0
contains(('господарств',)) = True           2 : 3  	=   257.8 : 1.0
contains(('багатоквартирн',)) = True        5 : 1  	=   234.7 : 1.0
contains(('колективн',)) = True            	3 : 1  	=   226.3 : 1.0

Для класифікації за забудованістю ділянок:

Найважливіші токени для класифікації за колонкою - built_up
Most Informative Features
contains(('цегл',)) = True               1 : 0  =  119.8 : 1.0
contains(('стоїт',)) = True              1 : 0  =  60.1 : 1.0
contains(('кухн',)) = True               1 : 0  =  60.1 : 1.0
contains(('цеглян',)) = True             1 : 0  =  58.3 : 1.0
contains(('літн',)) = True               1 : 0  =  53.0 : 1.0
contains(('одноповерхов',)) = True       1 : 0  =  49.1 : 1.0
contains(('опаленн',)) = True            1 : 0  =  45.2 : 1.0
contains(('підвал',)) = True             1 : 0  =  43.6 : 1.0
contains(('деревян',)) = True            1 : 0  =  38.6 : 1.0
contains(('горищ',)) = True              1 : 0  =  37.3 : 1.0

Метод show_most_informative_features() дозволяє досліджувати класифікатор, щоб визначити, які з властивостей (токенів), які він знайшов, найефективніші для класифікації. Наприклад, він показує, що слово «цегли» у 119,8 раза частіше свідчить про те, що ділянку забудовано.

Висновки

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

Звичайно, отримані результати класифікаторів можна покращити і покращувати їх якість я за допомогою бібліотеки scikit-learn — на мій особистий і суб’єктивний погляд, однієї із найкращих бібліотек, написаних на Python і точно з найкращою документацією, але про це вже в другій частині. А щодо бібліотеки NLTK, то це дуже хороша бібліотека для NLP з великим колом можливостей, що виходять далеко за межі цієї теми.

Рекомендовані джерела:


Читайте також наступні частини циклу:

LinkedIn

14 комментариев

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

Спасибо за статью и за ссылки. Только вот тут

Результати класифікації за забудованістю для класифікатора nltk.MaxentClassifier:

Результаты тоже Байесовского классификатора :)

Вообще проблема мультиклассового классификатора с несбалансированными классами в том что качество прогноза падает для меньших классов. Это видно и для приведённых результатов: выше всего точность у 1 и 3 класса, а у 4-7 хуже.

Пропустив. ВЕЛИКЕ ДЯКУЮ за коментар😃!

Стосовно проблеми із нерівномірно розподіленими класами все вірно. Але конкретно у моєму випадку вона більше виявляється при класифікації за забудованістю. При класифікації за типами ділянок на якість класифікації впливає та особливість, що класи 4,5 та 7 часто пов’язані з класом 1(я робив розмітку даних і навіть я не завжди міг зрозуміти який клас вибирати:-)). А з класом 6 усе ще простіше, слова «ідентифікатори» цього класу банально не потрапили у вокабуляр і не використовувались при побудові моделі. Цю проблему я вирішу у 2 частині статті.

Дякую за статтю! Надихає на створення подібних сервісів.

було цікаво почитати. Я хоч і не технар, але отримав задоволення )))

Дякую, за кейс з процессінгу українського тексту!

вроде в Украине есть команда которая разрабатывает корпусы на укр языке опенсорс, можно погуглить

Прекрасная статья!

Спасибо за статью. В свое время писал порт этой либы на C# - github.com/nrcpp/NltkNet.

ТС молодец, скрипты могут помочь во многих офисных задачах.

Подписаться на комментарии