Kodomo

Пользователь

numpy, matplotlib, svd (aka pca)

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

   1 >>> import codecs
   2 >>> anna = codecs.open("anna.txt", encoding='utf-8').read()
   3 >>> sonets = codecs.open("sonets.txt", encoding='cp1251').read()
   4 >>> anna_sentences = re.split(r'([.]\s*){3}|[.?!]', anna)
   5 >>> sonet_sentences = re.split(r'([.]\s*){3}|[.?!]', sonets)

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

Что такое среднеквадратичное отклонение?

Нам нужна какая-то мера, которая будет тем больше, чем дальше данные от центра, и будет игнорировать, в какую сторону дальше: направо или налево. В качестве этой меры выбирают x2, но с поправкой, что мы сначала должны его центрировать, т.е. x2 - s2 (где s – среднее). Дальше из всех этих значений берут среднее арифметическое. А чтобы данные стали снова похожи на ту же единицу измерения (правда, смысл ей мы придать обратно уже не сможем), из этого значения извлекают корень)1 :

   1 >>> import numpy as np
   2 >>> x = np.array([5,9,8,13,16])
   3 >>> s = np.mean(x)
   4 >>> x**2 - s**2
   5 array([ -79.04,  -23.04,  -40.04,   64.96,  151.96])
   6 >>> np.mean(x**2 - s**2)**.5
   7 3.8678159211627441
   8 >>> np.std(x)
   9 3.8678159211627432

Вернёмся к нашим даннным:

   1 >>> sonet_sentences = re.split(r'([.]\s*){3}|[.?!]', sonets)
   2 >>> len(anna_sentences)
   3 42997
   4 >>> len(sonet_sentences)
   5 2755
   6 >>> None in anna_sentences
   7 True

У нас получилось слишком много предложений и какие-то None.

Дело вот в чём, что re.findall, что re.split считают, что если в регулярном выражении есть что-то в скобках, то это непременно должно попасть в выдачу, ибо это что-то очень существенное (раз мы это разметили скобками!). findall в этом случае не выводит находку целиком, а выводит только содержимое скобок (всех, какие есть). split в этом случае бьёт текст на части, но между двумя соседними частями выводит содержимое каждой скобки, которая есть в выражении.

В нашем случае скобка не всегда оказывается с той стороны от |, чтобы в неё что-то попало, поэтому питон и вставляет None.

А лучше бы питон вообще не считал скобку как что-то особенное и только использовал её для ограничения области действия знаков. Такой тип скобок в питонских регулярных выражениях есть. Они открываются символом (?:, а закрываются символом ).

   1 >>> anna_sentences = re.split(r'(?:[.]\s*){3}|[.?!]', anna)
   2 >>> sonet_sentences = re.split(r'(?:[.]\s*){3}|[.?!]', sonets)
   3 >>> None in anna_sentences
   4 False
   5 >>> len(anna_sentences)
   6 21499
   7 >>> len(sonet_sentences)
   8 1378

Так-то лучше.

Теперь мы можем посчитать те данные, которые нам нужны для анализа:

   1 >>> def words(sentence):
   2         return [len(word) for word in sentence.split()]
   3 >>> anna_sentlens = [words(sentence) for sentence in anna_sentences if len(words(sentence)) > 0]
   4 >>> sonet_sentlens = [words(sentence) for sentence in sonet_sentences if len(words(sentence)) > 0]
   5 >>> anna_data = [(len(sentence), np.mean(sentence), np.median(sentence), np.std(sentence)) for sentence in anna_sentlens]
   6 >>> sonet_data = [(len(sentence), np.mean(sentence), np.median(sentence), np.std(sentence)) for sentence in sonet_sentlens]

Если у нас всё в порядке, то данные должны выглядеть примерно так:

   1 >>> anna_data[:10]
   2 [
   3   (25, 5.7599999999999998, 6.0, 3.0630703550522638),
   4         (5, 5.2000000000000002, 4.0, 3.2496153618543842),
   5         (25, 4.3200000000000003, 3.0, 4.8226133993924902),
   6         (18, 6.0555555555555554, 6.0, 3.9082849637692347),
   7         (32, 5.28125, 5.0, 3.0845823765138776),
   8         (12, 4.333333333333333, 4.0, 1.8408935028645437),
   9         (35, 5.5428571428571427, 5.0, 2.9601434052410096),
  10         (38, 4.4736842105263159, 5.0, 2.499861492007982),
  11         (35, 4.8571428571428568, 5.0, 2.4858784839035661),
  12         (5, 3.3999999999999999, 3.0, 0.4898979485566356)
  13 ]

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

   1 >>> from matplotlib import pyplot
   2 >>> anna_data = np.array(anna_data)
   3 >>> sonet_data = np.array(sonet_data)
   4 >>> pyplot.plot(anna_data[:,0], anna_data[:,1], 'o')
   5 [<matplotlib.lines.Line2D object at 0x06F73030>]
   6 >>> pyplot.show()

Функция plot получает на вход массив значений по x, массив значений по y (они должны быть одинаковой длины, тогда каждая пара значений с одинаковым номером будет задавать точку на плоскости), и параметры рисования (необязательно). Самые популярные параметры:

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

Про matplotlib есть:

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

Хочется нарисовать на одном графике и сонеты, и Анну Каренину, чтобы понять, насколько они друг на друга похожи или непохожи. Мы можем просто через запятую продолжать писать очередную пару комплектов координат и очередную настройку вида:

>>> pyplot.plot(anna_data[:,0], anna_data[:,1], 'og', sonet_data[:,0], sonet_data[:,1], 'xr')
[<matplotlib.lines.Line2D object at 0x0B6916F0>, <matplotlib.lines.Line2D object at 0x0B691850>]
>>> pyplot.show()

Метод главных компонент, PCA (он же разложение в опорные вектора, SVD)

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

Если у нас данные трёхмерные, то аналогия этого процесса совсем наглядная. У нас есть точки в трёхмерном пространстве, которые описывают какую-то фигуру. Мы поворачиваем эту фигуру так, чтобы вдоль оси OX у неё была наибольшая дисперсия, вдоль OY наибольшая из оставшегося, вдоль OZ наименьшая. То есть мы заходим к данным с такого бока, чтобы они были всегда шире, чем выше, чем глубже.

   1 >>> from matplotlib import mlab
   2 >>> p = mlab.PCA(anna_data, True)

Результат регрессии оказывается в p.Y. Мы можем нарисовать первые две оси из неё:

   1 >>> pyplot.plot(p.Y[:,0], p.Y[:,1], 'o')
   2 [<matplotlib.lines.Line2D object at 0x0B7B9EB0>]
   3 >>> pyplot.show()

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

Математика гласит нам, что любой поворот можно записать в виде преобразования координат вида x' = ax + by + c, y' = dx + ey + f. Мы можем вытащить из результатов регрессии параметры этого преобразования.

Ещё мы можем из регрессии узнать, собственно, преобразования, которые она вычислила для наших данных. Если у нас на вход был вектор a[i], то результат считается ровно по такой формуле: P.Y[i] = a[i]*p.W + p.s (под знаком * здесь имеется в виду умножение вектора на матрицу в том же смысле, как и в линейной алгебре)

   1 >>> p.s
   2 array([ 48434.82780202,  22161.49155042,  12391.53589483,   1944.14475272])
   3 >>> p.Wt
   4 array([[-0.28208233, -0.61801142, -0.56256036, -0.4711871 ],
   5        [-0.78307567,  0.27494206,  0.40866314, -0.37972858],
   6        [-0.55276073, -0.01956336, -0.33881946,  0.76110066],
   7        [ 0.04095874,  0.73626491, -0.63381516, -0.23348385]])

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

   1 >>> data = np.vstack((anna_data, sonet_data))
   2 >>> p = mlab.PCA(data, True)
   3 >>> N = len(anna_data)
   4 >>> N
   5 21233

   1 >>> pyplot.plot(p.Y[:N,0], p.Y[:N,1], 'rx', p.Y[N:0], p.Y[N:1], 'gx')
   2 [<matplotlib.lines.Line2D object at 0x0B7979F0>, <matplotlib.lines.Line2D object at 0x0B797D90>, <matplotlib.lines.Line2D object at 0x0B797350>, <matplotlib.lines.Line2D object at 0x0B7978B0>, <matplotlib.lines.Line2D object at 0x0B7972F0>]
   3 >>> pyplot.show()

Что-то не то. Если приглядеться, выясняем, что p.Y[N:1] обозначает совсем не то же, что и p.Y[N:,1]

>>> pyplot.plot(p.Y[:N,0], p.Y[:N,1], 'rx', p.Y[N:,0], p.Y[N:,1], 'gx')
[<matplotlib.lines.Line2D object at 0x0B44B2B0>, <matplotlib.lines.Line2D object at 0x0B43B5D0>]
>>> pyplot.show()
  1. Могли бы с тем же успехом вместо sqrt(mean(x2-s2)) использовать mean(|x-s|), но традиция сложилась другая. (1)