Во время работы с данными важно понять, что они собой представляют. Не всегда на первый взгляд можно понять их структуру, свойства и особенности. В частности, это касается и текстовых данных, которые сами по себе не имеют четкой структуры. Для этого существует EDA (Exploratory data analysis), или разведочный анализ данных.
В этой статье мы рассмотрим этапы анализа текстовых данных, а также подходы при работе с датасетами для таких популярных задач NLP, как классификация и NER/POS. В качестве основных инструментов будут использоваться Python и Jupyter Notebook.
Содержание
Первичный анализ датасета
Для демонстрации возьмем открытый датасет с Kaggle, представляющий собой набор рецептов. Ссылка на датасет.

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

Не всегда можно получить подробное описание данных, но если вдруг оно имеется, стоит его изучить перед работой с датасетом.
Например на 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 следующий:
Сначала текст переводится в векторное представление (более подробно об этом можно почитать в моей предыдущей статье здесь);
Метод LDA предполагает, что заранее будет указано количество тем в корпусе. Это может быть сложной задачей. Данный параметр подбирается в зависимости от знаний о данных или эмпирически во время экспериментов;
Происходит обучение модели LDA, которая итеративно обрабатывает тексты, и старается максимизировать появления слов в каждой теме и вероятность наличия тем в каждом документе.
Перед началом применения LDA необходимо провести подготовку данных, а именно:
Провести токенизацию (разбить текст на массив слов);
Удалить стоп‑слова;
Лемматизировать (привести слова к начальной форме. Например: сделано — делать, прыгающий — прыгать).
Лемматизация необходима для того, чтобы убрать ситуации, когда одно и то же слово имеет разную форму, но одно и то же значение (например: делаю, делал, сделаю, …). Для 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. Ссылка.

Датасет представляет собой набор документов, для каждого из которых имеются теги частей речи, а также теги для именованных сущностей.
df = pd.read_csv("./ner.csv")
df.head()

Всего 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 теги состоят из существительных, предлогов, союзов и прилагательных.
Далее взглянем на распределение 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()

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

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

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

Заключение
В этой статье я рассказал про базовые подходы к разведочному анализу текстовых данных. Используя эти подходы, вы сможете лучше узнать свои данные, а, возможно, и обнаружите какие-то инсайты.
Старайтесь всегда проводите EDA перед работой с данными!
Подписывайтесь на мой Telegram канал, в котором я рассказываю интересные вещи об IT и AI технологиях.
Комментарии (2)

Liugger
08.11.2025 23:39В статье показаны некоторые методы обработки текстов, чтобы вытянуть некоторые показатели - хорошо для новичка в текстовом анализе. Однако не хватает цели: анализ же никогда не делается для того чтобы что-то посчитать, показатели нужны для выводов.
Сам сталкивался с разведочным анализом текстов, но в моем случае было 2 цели:
1) облегчить работу data scientist, чтобы они на EDA время не тратили
2) ускорить понимание корпуса документов любому члену команды, кому резко потребовалось поработать с теми текстами, что уже попали мне в руки.
Исходя из вышеописанного я бы в набор тех методов, которые описаны в статье, добавил:
1) Расчет TF-IDF и оценку того какая доля уникальных слов покрывает объем всех слов по частоте и по TF-IDF. То есть на выходе получить значения вида: 20% уникальных слов покрывает 50% вхождений всех слов и 35% суммарного TF-IDF.
Так мы поймем насколько уникальны документы и насколько большой должен быть словарь для векторизации через BoW или TF-IDF
2) добавить ключевые слова для тем. Не только расчетом наиболее частотных, но и с помощью, либо легких алгоритмов YAKE, RAKE, либо путем поиска ближайших слов к центроиду тем.
Так мы поймем о чем наши тематики более точно
3) рассчитать все показатели не только для отдельных слов, но и для 2, 3 - грамм. Зачастую n-граммы помогают лучше понять о чем текст.
Так мы улучшим понимание текстов для тех, кто читает наш анализ. В случае, если тексты не сильно уникальные (например отзывы на парикмахерскую), то уже на 3-граммах можно будет отловить основные паттерны и более глублокий анализ может даже не потребоваться.
4) оценка изменения тем во времени. Например, через подсчет кол-ва документов тематик в каждый месяц. Изменение распределения документов в темах от месяца к месяцу даст нам информацию о том, как быстро сменяются (читай, дрифтуют) основные темы в корпусе документов. В случае отзывовов мы поймем ушли ли старые проблемы и добавились ли новые.
5) если есть категориальные переменные, то добавить оценку взаимной информации между темами и категориями. Так мы поймем что у нас есть реальная связь между темой текста и его категорией. Отсюда поймем надо ли тюнить наш алгоритм поиска тем или может действительно связи нет.
6) К LDA я бы добавил LSA (TF-IDF + SVD) и кластеризацию для поиска тематик, так как гадать над количеством тем не очень интересно. В свою очередь такой ручной поиск тематик через кластеризацию дает больше управления и позволяет сделать выводы о том насколько документы однородны. Например, если нас устраивает кластерищация через k-means, значит темы в корпусе документов более-менее четкие. Если же через k-means получается ерунда, но нас устроила клстеризация через (H)DBSCAN, значит темы в документах "расплывчатые" могут перетекать из одной в другую, приводя к тому, что есть множества документов, где каждый похож друг на друга, но все они либо без четкой границы, либо перетекают из темы в тему в рамках макротемы.
schulzr
Что такое EDA в Вашей статье? Да и другие трехбуквенные сокращения не пояснены. Спасибо