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

Мы дополнили такой подход ещё одной эвристикой: сделали сжатие текстов с помощью zlib и отбросили самые сильно и слабо сжимающиеся, а затем уже  применили классификацию. Эмпирически подобранный диапазон сжатия для нормального текста ?1.2—?8 (меньше 1.2 — случайные символы и технический мусор, больше 8 — шаблонный спам). 

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

Возьмем текстовый корпус, состоящий из предложений, длина которых варьируется в диапазоне от 50 до 280 символов:

import zlib
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.optimize import curve_fit

with open('/content/test.txt', 'r', encoding='utf-8') as f:
  text = f.read()
sntc = text.split('\n')

l_sntc = []  # длины предложений
k_zlib = []  # коэффициенты сжатия для каждого предложения

for s in sntc:
  l_sntc.append(len(s))
  k_zlib.append(len(s) / len(zlib.compress(s.encode(), -1)))

Посмотрим, как длина качественных предложений влияет на коэффициент сжатия

Для этого:

1. Возьмем диапазон длин предложений с наибольшей частотой (25 - 75 перцентиль). В нашем случае это длины от 92 до 175 символов:

mp_1 = np.percentile(np.array(l_sntc), [25, 75])
print('Диапазон: ' + str(mp_1))

2. Разобьем эти предложения на примерно одинаковые по длине группы. Максимальное отклонение длин предложений в одной группе будет равняться отклонению между 25 и (25 + w) перцентилем или 75 и (75 - w) перцентилем (выбираем меньшее из двух дельт), где w - окно (принимаем 2.5 перцентиля).

  • считаем максимальное отклонение длин предложений в одной группе:

w = 2.5  # окно для перцентилей
mp_2 = np.percentile(np.array(l_sntc), [25 + w, 75 - w])
dl = int(min(mp_2[0] - mp_1[0], mp_1[1] - mp_2[1]))
print('Максимальное отклонение длин предложений в одной группе: ' + str(dl))

Максимальное отклонение длин предложений в одной группе на нашем примере составляет 3 символа.

  • разбиваем предложения на группы по длине +- 3 символа:

# сортируем список с длинами предложений
id_sntc = range(len(sntc))  # порядковые номера предложений
x = zip(l_sntc, id_sntc)
xs = sorted(x, key = lambda tup: tup[0])
l_sntc_s = [x[0] for x in xs]
id_sntс_s = [x[1] for x in xs]

gr = 0  # количество групп
k_gr = [[]]  # коэффициенты сжатия всех предложений в группе
l_gr = [[]]  # длины всех предложений в группе
sl0 = l_sntc_s[l_sntc_s.index(mp_1[0])] # начальное значение длины в группе

nt = l_sntc_s.index(mp_1[1])
for i in range(nt, len(l_sntc_s)):
    if l_sntc_s[i] > l_sntc_s[nt]:
        nt = i
        break

for i in range(l_sntc_s.index(mp_1[0]), nt):
    if l_sntc_s[i] > sl0 + dl:
        sl0 = l_sntc_s[i]
        k_gr.append([])
        l_gr.append([])
        gr += 1
    else:
        k_gr[gr].append(k_zlib[id_sntс_s[i]])
        l_gr[gr].append(l_sntc_s[i])

print('Количество групп: ' + str(gr))

Получаем 20 групп.

3. Предполагаем, что пятидесятому перцентилю каждой группы соответствует качественное предложение и посмотрим на график зависимости коэффициента сжатия этих предложений от их длины:

x = [0]
y = [0]

for i in range(gr + 1):
    x.append(np.percentile(np.array(l_gr[i]), 50))
    y.append(np.percentile(np.array(k_gr[i]), 50))
  • наблюдаем степенную функцию вида:

y = a \cdot  x^{b},

где x - длина предложения, y - нормальный коэффициент сжатия для качественного предложения.

Аппроксимируем функцию с помощью МНК

x = np.array(x)
y = np.array(y)

#  МНК
def func(x, a, b):
    return a * x ** b

popt, pcov = curve_fit(func, x, y, (0.27, 0.24), maxfev=10 ** 6)
a, b = popt

print('a = {0}\nb = {1}'.format(*tuple(popt)))
print('Коэффициент корреляции: ' + str(np.corrcoef(y, a * x ** b)[0][1]))

a = 0.17601951773514363, b = 0.3256903074228561, коэффициент корреляции: 0.9999489378452683

для нашего корпуса текстов получаем зависимость:

y = 0.18 \cdot  x^{0.33},

Изобразим графически разницу между полученной степенной функцией (на всём диапазоне длин 50 - 280 символов) и постоянным коэффициентом сжатия, если бы он не зависел от длин. Для этого вводим переменную "c" для функции "y = с" (на пятидесятом перцентиле), которую принимал бы нормальный коэффициент сжатия, если бы он не зависел от длины:

c = np.percentile(np.array(k_zlib), 50)

graph = plt.figure()
axes = graph.add_axes([0, 0, 1, 1])
axes.set_xlabel('Длина предложения')
axes.set_ylabel('Коэффициент сжатия')
axes.set_title('Зависимость коэффициента сжатия от длины предложений')
axes.plot([60, 280], [c, c], color='r')
axes.plot(range(60, 281), a * np.array(range(60, 281)) ** b, color='b')

Разница более чем существенная. Коэффициент сжатия для предложений короче ~130 символов будет заниженным, а для предложений длиннее ~130 символов - наоборот завышенным. И это видно невооруженным взглядом на практике. Если отсеивать предложения разной длины по коэффициенту сжатия без корректировки, то отсеются преимущественно более длинные предложения.

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

Делаем поправку коэффициента сжатия всех предложений в зависимости от их длины

k_zlib_f = np.array(k_zlib) * c / (a * np.array(l_sntc) ** b)

Напоследок посмотрим на примере, какие предложения отсеиваются после корректировки и не отсеялись бы без неё:

  • в нашем случае предложения уже очищены от технического мусора, поэтому в качестве примера отсеиваем только заспамленные предложения:

p_zlib_1 = np.percentile(np.array(k_zlib), 99.95)
p_zlib_2 = np.percentile(np.array(k_zlib_f), 99.95)

for i in range(len(sntc)):
  if k_zlib_f[i] > p_zlib_2 and k_zlib[i] <= p_zlib_1:
    print(sntc[i])

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

Полный код ноутбука выложен на Гитхабе.

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