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

В этой статье мы рассмотрим этапы анализа текстовых данных, а также подходы при работе с датасетами для таких популярных задач NLP, как классификация и NER/POS. В качестве основных инструментов будут использоваться Python и Jupyter Notebook. 

Содержание

Первичный анализ датасета

Для демонстрации возьмем открытый датасет с Kaggle, представляющий собой набор рецептов. Ссылка на датасет.

Датасет рецептов
Датасет рецептов

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

Карточка данных на Kaggle
Карточка данных на Kaggle

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

Например на Hugging Face датасеты также могут иметь карточки с описанием

Карточка данных на Hugging Face
Карточка данных на Hugging Face

Вернемся к нашим рецептам.

В карточке сказано, что из себя представляет датасет (набор рецептов), его размер (64.000 рецептов), имеющиеся категории (320 шт.), а также описание каждого столбца в таблице. Рецепты написаны на английском языке.

Немаловажной информацией является формат самого датасета и его внутренней структуры. Сам файл датасета имеет формат CSV, а информация в столбцах с ингредиентами и инструкциями хранится в формате JSON. 

Давайте теперь перейдем к анализу данных в Jupyter Notebook. 

Сначала необходимо установить все необходимые библиотеки и импортировать их

# pip matplotlib seaborn nltk pandas numpy spacy gensim
import matplotlib.pyplot as plt
import seaborn as sns

import nltk
from nltk.corpus import stopwords
import spacy
import gensim

from collections import Counter

import pandas as pd
import numpy as np
import json

Дубликаты и пропуски

В качестве инструмента для анализа данных мы будем использовать Pandas. Это библиотека, предназначенная для обработки и анализа табличных данных.

Считаем датасет

file_path = "./1_Recipe_csv.csv"
df = pd.read_csv(file_path)

Узнаем точный размер датасета

num_rows, num_columns = df.shape

print(f"{num_rows=}")
print(f"{num_columns=}")

# num_rows=62126
# num_columns=8

Видим, что фактически в датасете 62 126 строк данных, в отличие от заявленных 64 000 в карточке на Kaggle. 

Взглянем на несколько первых строк

df.head()
Обзор данных
Обзор данных

Как и было сказано в описании, данные содержат название рецепта, категорию и подкатегорию, список ингредиентов, пошаговую инструкцию, а также количества ингредиентов и шагов.

Проверим данные на наличие пропусков и дубликатов

print(df.isna().sum())
# recipe_title       0
# category           0
# subcategory        0
# description        0
# ingredients        0
# directions         0
# num_ingredients    0
# num_steps          0
# dtype: int64

print(df.duplicated().sum())
# 0

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

Статистика по длине и частотности

Для упрощения демонстрации анализа создадим столбец с рецептами в виде текста. Объединим в единый текст последовательность шагов в столбце directions (пошаговая инструкция рецепта).

df['directions_str'] = df['directions'].map(lambda x: "\n\n".join(json.loads(x)))
df.head()
Добавили строковое представление рецепта
Добавили строковое представление рецепта

Построим гистограммы распределения количества символов в каждом рецепте.

df['directions_str'].str.len().hist(bins="auto")
Распределение длины документов
Распределение длины документов
min_len = df['directions_str'].str.len().min()
max_len = df['directions_str'].str.len().max()
print(f"{min_len=}\n{max_len=}")

# min_len=12
# max_len=5879

Видим, что длина рецептов составляет от 12 до 5879, а также по гистограмме можно сделать вывод, что чаще всего длина рецепта примерно равна от 100 до 800. Реже бывают длинные рецепты с более чем 2000 символами.

Теперь построим диаграмму на основе количества слов.

df['directions_str'].str.split().map(lambda x: len(x)).hist(bins="auto")
plt.xlabel('Количество слов в документе')
plt.ylabel('Частота')
plt.show()
Распределение количества слов в документах
Распределение количества слов в документах

Видим схожую картину. Чаще всего количество слов в рецептах примерно равно от 50 до 150.

Теперь рассмотрим среднюю длину слова в предложениях.

df['directions_str'].str.split().apply(lambda x : [len(i) for i in x]).map(lambda x: np.mean(x)).hist(bins="auto")
plt.xlabel('Средняя длина слова в документе')
plt.ylabel('Частота')
plt.show()
Распределение средней длины слова в предложении
Распределение средней длины слова в предложении

Здесь уже видим более «нормальное» распределение. На первый взгляд можно сказать, что длина слова в рецептах примерно от 4.5 до 5. Но стоит обратить внимание на такую вещь как — стоп слова.

Стоп‑слова — это предлоги, союзы, местоимения, частицы и другие часто встречающиеся слова, которые связывают текст, и не всегда добавляют ценной информации. (например в русском языке: ну, а, и, и так далее).

Чтобы получить список стоп‑слов можно воспользоваться библиотекой nltk

# Загрузка стоп-слов
nltk.download('stopwords')
stop=set(stopwords.words('english'))
print(stop)
Стоп-слова для английского языка
Стоп‑слова для английского языка

Также стоп‑слова можно получить и для русского языка.

stop_ru = set(stopwords.words('russian'))
print(stop_ru)
Стоп-слова для русского языка
Стоп‑слова для русского языка

Продолжим работу с английским набором.

Посмотрим на статистику стоп‑слов в нашем корпусе текста. Для этого сначала соберем в единый список все имеющиеся слова в датасете рецептов.

corpus=[]
sentences = df['directions_str'].tolist()
corpus_words = [word for sentence in sentences for word in sentence.split()]

И подсчитаем частоту встречаемости каждого стоп‑слова

count_words = {}
for word in corpus_words:
    if word in stop:
        count_words[word] = count_words.get(word, 0) + 1

Теперь отобразим частотность стоп‑слов на диаграмме

top_stop_words=sorted(count_words.items(), key=lambda x:x[1], reverse=True)[:10] 
x, y=zip(*top_stop_words)
plt.bar(x, y)
Распределение стоп-слов в датасете
Распределение стоп‑слов в датасете

Видим следующее: наиболее часто встречающиеся стоп‑слова это and, the, a.

Также мы можем посчитать наиболее часто встречающиеся слова во всем корпусе текста.

Посчитаем частоту слов во всем корпусе, исключая при этом стоп‑слова:

counter = Counter(corpus_words)
most=counter.most_common()
x, y= [], []
for word,count in most[:40]:
    if (word not in stop) and len(word) > 1:
        x.append(word)
        y.append(count)

Отобразим диаграмму

# отображение текста
plt.bar(x, y)
plt.xticks(
    rotation=45,      
    ha='right',       
    fontsize=10      
)
plt.tight_layout() 
Распределение слов в корпусе, за исключением стоп-слов
Распределение слов в корпусе, за исключением стоп‑слов

Видим следующее: одни из наиболее популярных слов в корпусе это: minutes, degrees и oven. Кажется достаточно очевидным для датасета с кулинарными рецептами.

Еще один способ визуализации частотности слов это WordCloud (или облако слов).

def show_wordcloud(data):
    wordcloud = WordCloud(
        background_color='white',
        stopwords=stop,
        max_words=100,
        max_font_size=30,
        scale=3,
        random_state=1)

    wordcloud=wordcloud.generate(str(data))

    fig = plt.figure(1, figsize=(12, 12))
    plt.axis('off')

    plt.imshow(wordcloud)
    plt.show()

show_wordcloud(corpus_words)
Облако слов для корпуса с рецептами
Облако слов для корпуса с рецептами

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

Также для корпуса текста можно рассчитать показатель лексического богатства (или лексического разнообразия). 

Коэффициент лексического разнообразия (КЛР) — это мера богатства лексики текста, которая показывает, насколько часто в нем встречаются уникальные слова по отношению к общему числу слов. Измеряется КЛР от 0 до 1. Соответственно, чем больше это значение, тем более разнообразнее текст, и наоборот, чем меньше КЛР, тем лексически беднее текст.

Посчитаем КЛР для нашего датасета

lexical_diversity = (len(set(corpus_words)) / len(corpus_words)) * 100
print(f"{lexical_diversity=}")
# lexical_diversity=0.29431734886771344

Значение примерно равно 0.3. Можно сделать вывод, что в данном корпусе рецептов относительно небольшое лексическое разнообразие слов (много повторений).

Тематические моделирование

Тематическое моделирование — это метод статистического анализа, посредством которого извлекаются основные темы, встречающиеся в корпусе документов.

Рассмотрим такой вид анализа на примере одного из наиболее популярных методов тематического моделирования — Latent Dirichlet Allocation (LDA).

Принцип LDA следующий: 

  1. Сначала текст переводится в векторное представление (более подробно об этом можно почитать в моей предыдущей статье здесь); 

  2. Метод LDA предполагает, что заранее будет указано количество тем в корпусе. Это может быть сложной задачей. Данный параметр подбирается в зависимости от знаний о данных или эмпирически во время экспериментов;

  3. Происходит обучение модели LDA, которая итеративно обрабатывает тексты, и старается максимизировать появления слов в каждой теме и вероятность наличия тем в каждом документе.

Перед началом применения LDA необходимо провести подготовку данных, а именно: 

  1. Провести токенизацию (разбить текст на массив слов);

  2. Удалить стоп‑слова;

  3. Лемматизировать (привести слова к начальной форме. Например: сделано — делать, прыгающий — прыгать).

Лемматизация необходима для того, чтобы убрать ситуации, когда одно и то же слово имеет разную форму, но одно и то же значение (например: делаю, делал, сделаю, …). Для LDA нам нужны именно уникальные слова.

Для подготовки текста воспользуемся библиотекой spaCy

# установка ядра для spacy
nlp = spacy.load("en_core_web_sm", enable=["lemmatizer"])

# для ускорения беру часть от датасета
df_tmp = df.iloc[:10000]

all_lemmas = []
for text in df_tmp ['directions_str'].tolist():
    # Обрабатываем текст моделью spaCy
    doc = nlp(text)
    
    # Создаем список для хранения лемм, исключая стоп-слова и знаки препинания
    lemmas = [token.lemma_ for token in doc if not token.is_stop and not token.is_punct]

    all_lemmas.extend(lemmas)

Далее получим векторное представление, используя BOW (мешок слов) 

bow_corpus = [dic.doc2bow(doc.split()) for doc in all_lemmas]

После производим запуск LDA для осуществления тематического моделирования

# количество тем беру отталкиваясь от количества категорий в данных
num_topics = len(df_tmp['category'].unique())
lda_model = gensim.models.LdaMulticore(bow_corpus,
                                   num_topics = num_topics ,
                                   id2word = gensim.corpora.Dictionary([words.split() for words in all_lemmas]),
                                   passes = 10,
                                   workers = None,
                                batch=True)

Чтобы визуализировать полученный результат можно использовать библиотеку pyLDAvis

pyLDAvis.enable_notebook()
vis = gensim_lda.prepare(lda_model, bow_corpus, dic)
vis
Визуализация тематического моделирования
Визуализация тематического моделирования

На диаграмме каждый круг — это выделенная тема. Размер круга — важность темы, относительно всего корпуса (большой круг — много рецептов в этой категории, маленький — наоборот). Расстояние между кругами — схожесть тем друг от друга (чем дальше, тем менее схожи). 

Справа гистограмма показывает наиболее релевантные слова для выбранной темы.

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

Далее мы рассмотрим подход к анализу текста для некоторых популярных задач NLP.

Классификация текста

Рассмотрим пример анализа данных для задачи классификации текста. 

В качестве примера возьмем датасет с Kaggle, который представляет собой набор текстовых документов, разделенным по различным категориям.

Датасет для задачи классификации документов
Датасет для задачи классификации документов

По описанию можно увидеть, что документы разделены на 5 классов: политика, спорт, технологии, развлечения и бизнес. 

В датасете категории разделены цифрами, в описании сказано, что разделение меток следующее: политика= 0, спорт= 1, технологии= 2, развлечения = 3, бизнес = 4.

Переходим к анализу самих данных. 

df = pd.read_csv("./classification dataset.csv")
df.head()
Набор документов и категорий
Набор документов и категорий
num_rows, num_columns = df.shape
print(f"{num_rows=}")
print(f"{num_columns=}")
#num_rows=2225
#num_columns=2

Датасет содержит только 2 столбца — с текстом документа и его категорией. Всего 2225 строк данных.

Проверим наличие пропусков и дубликатов.

print(df.isna().sum())
# Text  0
# Label 0

print(df.duplicated().sum())
# 98

Пропуски отсутствуют, но в данных присутствует 98 дубликатов. Избавимся от них

df = df.drop_duplicates()

Рассмотрим распределение классов

d_categories = {0: "политика", 1: "спорт", 2: "технологии", 3: "развлечения", 4: "бизнес"}

# переводим категории из цифр в названия
df['Label_str'] = df['Label'].map(d_categories)

plt.figure(figsize=(8, 5))
sns.countplot(x='Label_str', data=df)
plt.title('Распределение классов')
plt.show()
Распределение классов в датасете
Распределение классов в датасете

Видим, что документов категорий «спорт» и «бизнес» чуть больше чем остальных, но в целом сильного дисбаланса классов в данных нет.

Попробуем посмотреть на корреляцию длины текста и количества слов в документе с категорией. Для визуализации воспользуемся графиком «Ящик с усами».

# считаем длину каждого документа и количество слов в каждом
df['text_length'] = df['Text'].apply(len)
df['word_count'] = df['Text'].apply(lambda x: len(x.split()))

# Создаем фигуру с двумя подграфиками
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))

# Левый график - длина документов
sns.boxplot(data=df, x='Label_str', y='text_length', ax=ax1)
ax1.set_title('Зависимость длины документов от категории')
ax1.set_xlabel('Категория документа')
ax1.set_ylabel('Длина документа (символы)')
ax1.tick_params(axis='x', rotation=45)
ax1.grid(True, alpha=0.3)

# Правый график - количество слов
sns.boxplot(data=df, x='Label_str', y='word_count', ax=ax2)
ax2.set_title('Зависимость количества слов от категории')
ax2.set_xlabel('Категория документа')
ax2.set_ylabel('Количество слов')
ax2.tick_params(axis='x', rotation=45)
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
Графики "Ящик с усами" для данных
Графики «Ящик с усами» для данных

Из графиков можно сделать вывод, что не наблюдается сильной зависимости категорий от длины документа и количества слов в документах, но видно, что в среднем документы с категориями «политика» и «технологии» имеют большую длину чем другие.

NER, POS

Немного затронем анализ данных для таких задач в NLP как NER и POS. 

NER (Named Entity Recognition) — это задача автоматического поиска и классификации именованных сущностей в тексте (например, «[Организация Apple] планирует открыть офис в [Город Лондоне] в [Дата 2024 году]»).

POS (Part‑of‑Speech) — это задача определения грамматической роли каждого слова в предложении (например, «Красивая кошка грациозно прыгнула на стол» — «Прилагательное, Существительное, Наречие, Глагол, Предлог, Существительное»).

Датасет также возьму в Kaggle. Ссылка.

NER датасет с Kaggle
NER датасет с Kaggle

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

df = pd.read_csv("./ner.csv")
df.head()
Датасет содержит POS и NER теги
Датасет содержит POS и NER теги

Всего 4 столбца, для каждого предложения, как и сказано в описании, имеются теги для POS и NER. 

Полную расшифровку POS тегов можно посмотреть здесь

Про NER в карточке датасета было сказано что имеются следующие теги:

  • geo = Географическая сущность

  • org = Организация

  • на = человека

  • gpe = Геополитическая сущность

  • tim = Индикатор времени

  • искусство = Артефакт

  • канун = событие

  • nat = Природное явление

Посмотрим на количество данных

num_rows, num_columns = df.shape
print(f"{num_rows=}")
print(f"{num_columns=}")
#num_rows=47959
#num_columns=4

Всего 47 959 строк в датасете. 

Также можно проверить наличие дубликатов и пропусков в данных

print(df.isna().sum())
#Sentence    0
#POS         0
#Tag         0

print(df.duplicated().sum())
# 344

Пропусков нет, но имеются дубликаты. Очистим данных от них

df = df.drop_duplicates()

Можно также проанализировать статистику по длинам документов, количеству слов в них и так далее. Перейдем сразу к анализу тегов. Изучим их распределения.

Преобразуем строки в списки

df['POS_list'] = df['POS'].apply(ast.literal_eval)
df['Tag_list'] = df['Tag'].apply(ast.literal_eval)

Начнем с POS тегов.

# получаем список всех тегов, кроме пунктуаций
all_pos = [tag for sublist in df['POS_list'] for tag in sublist if tag not in [".", ","]]
# считаем количество каждого тега
pos_counts = Counter(all_pos)
# выбираем 15 наиболее встречающихся тегов
top_pos = dict(pos_counts.most_common(15))

plt.bar(top_pos.keys(), top_pos.values(), color='lightgreen');
plt.title('Топ-15 POS-тегов');
plt.xticks(rotation=45);
Распределение POS тегов в датасете
Распределение POS тегов в датасете

Видим, что преимущественно в POS теги состоят из существительных, предлогов, союзов и прилагательных.

Далее взглянем на распределение NER тегов. Построим диаграмму

# получаем список всех тегов и считаем их количество
all_tags = [tag for sublist in df['Tag_list'] for tag in sublist if tag != "O"]
tag_counts = Counter(all_tags)
# выбираем 15 наиболее встречающихся тегов
top_tags = dict(tag_counts.most_common(15))

plt.bar(top_tags.keys(), top_tags.values(), color='salmon')
plt.title('Топ-15 NER-тегов')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
Распределение NER тегов
Распределение NER тегов

По диаграмме видно, что больше всего в данных имеются следующие сущности: географические места, времена и организаций. 

Также, если ваши данные имеют следующий формат

Данные с форматом, где теги и слова стоят в одной строке
Данные с форматом, где теги и слова стоят в одной строке

Можно при помощи 2 строк кода получить количество уникальных слов на каждый NER тег, а также наиболее популярное слово для каждого из них

Получение более подробной статистики для NER тегов
Получение более подробной статистики для NER тегов

Тоже самое можно сделать и для POS тегов:

Получение более подробной статистики для POS тегов
Получение более подробной статистики для POS тегов

Заключение

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

Старайтесь всегда проводите EDA перед работой с данными!

Подписывайтесь на мой Telegram канал, в котором я рассказываю интересные вещи об IT и AI технологиях.

Комментарии (1)


  1. schulzr
    08.11.2025 23:39

    Что такое EDA в Вашей статье? Да и другие трехбуквенные сокращения не пояснены. Спасибо