Частотный словарь и биграммы по постам инвесторов
⏱ Время чтения текста – 16 минутТинькофф Инвестиции — сервис от Тинькофф Банка для инвестирования на Московской и Санкт-Петербургской биржах. Внутри сервиса есть социальная сеть «Пульс», где инвесторы любого уровня могут делиться своими опытом, мыслями и планами, комментировать и оценивать чужие посты. Сегодня решим такую задачу:
построим частотный словарь и биграммы по постам пользователей, разделив их по объёму портфеля, чтобы понять, чем отличаются посты людей с разным объёмом инвестиций.
Лента по ценной бумаге в Пульсе выглядит вот так:

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

Схема в Clickhouse
Для биграмм и частотного словаря достаточно собрать только тексты постов, логины пользователей и объёмы портфеля, но ради спортивного интереса будем хранить ещё и рост портфеля, число сделок человека за месяц и количество оценок под его постом. Для хранения данных получится две таблицы posts и users:
CREATE TABLE tinkoff.posts
(
`login` String,
`post` String,
`likes` Int16
)
ENGINE = MergeTree
ORDER BY login
CREATE TABLE tinkoff.users
(
`login` String,
`volume_prefix` String,
`volume` String,
`year_stats_prefix` String,
`year_stats` String
)
ENGINE = MergeTree()
ORDER BY login
В таблице с пользователями volume_prefix — это префикс «до» или «от», стоящий в объёме портфеля, а volume — сам объём портфеля. Соответственно years_stats_prefix обозначает, портфель за год упал или вырос, а year_stats — на сколько он упал или вырос. Такая схема из двух таблиц с ключом сортировки таблиц по полю login позволит их соединить позднее.
Пишем парсер постов
У Пульса нет своего API, поэтому для парсинга постов будем использовать Selenium.
Мы уже писали про то, как парсить сайты с прокруткой при помощи Selenium
Нам понадобятся следующие библиотеки:
from selenium import webdriver
import time
from webdriver_manager.chrome import ChromeDriverManager
from clickhouse_driver import Client
from bs4 import BeautifulSoup as bs
import pandas as pd
import requests
import os
from lxml import html
import re
Сразу составим список интересующих ценных бумаг: это акции Сбербанка, Газпрома, Яндекса, Лукойла, MailRu, Аэрофлота, Киви, ВТБ, Детского Мира и Ленты. Для каждой бумаги будем переходить на страницу с постами, в которых она упоминается и проматывать страницу, пока длина страницы не станет более 300000: это около одной недели.
driver = webdriver.Chrome(ChromeDriverManager().install())
securities = ['SBER', 'GAZP', 'YNDX', 'LKOH', 'MAIL', 'AFLT', 'QIWI', 'VTBR', 'DSKY', 'LNTA']
for security in securities:
try:
print(security)
driver.get(f'https://www.tinkoff.ru/invest/stocks/{security}/pulse/')
page_length = driver.execute_script("return document.body.scrollHeight")
while page_length < 300000:
driver.execute_script(f"window.scrollTo(0, {page_length - 1000});")
page_length = driver.execute_script("return document.body.scrollHeight")
После забираем себе весь код полученной страницы и извлекаем нужную информацию: логины, текст постов и число лайков. Формируем из них DataFrame и записываем его в папку data.
source_data = driver.page_source
soup = bs(source_data, 'lxml')
posts = soup.find_all('div', {'class':'PulsePostCollapsed__text_1ypMP'})
logins = soup.find_all('div', {'class':'PulsePostAuthor__nicknameLink_19Aca'})
likes = soup.find_all('div', {'class':'PulsePostBody__likes_3qcu0'})
logins = [login.text for login in logins]
posts = [post.text for post in posts]
likes = [like.text.split()[0] for like in likes]
df_posts = pd.DataFrame()
df_posts['login'] = logins
df_posts['post'] = posts
df_posts['likes'] = likes
df_posts.to_csv(f'data/{security}.csv', index=False)
print(f'SAVED {security}')
except Exception as E:
print(E)
После того, как нужные посты собраны, отправим их в таблицу posts в Clickhouse. При помощи модуля os переходим в директорию data и собираем в список all_files названия всех файлов в ней — это все csv-таблицы, которые мы спарсили. Затем по очереди читаем файл в DataFrame и вставляем в posts.
client = Client(host='', user='', password='', port='9000', database='tinkoff')
os.chdir('data')
all_files = os.listdir()
for file in all_files:
df = pd.read_csv(file)
client.execute("INSERT INTO posts VALUES", df.to_dict('records'))
Собираем информацию о профилях
Чтобы собрать все профили, получим уникальный список логинов из базы:
flatten = lambda t: [item for sublist in t for item in sublist]
logins = flatten(client.execute("SELECT DISTINCT login FROM posts"))
Получать посты будем request-запросом, без Selenium, ведь ничего листать уже не нужно. Но иконка падения или роста портфеля за год не является текстом и получить её нельзя, зато внутри CSS-стилей можно увидеть её цвет — его мы и будем сохранять себе.

Поэтому опишем такую функцию: она примет объект soup и извлечёт цвет иконки.
headers = {'accept': '*/*',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'}
def up_or_down_color(soup):
string = str(soup.find('div', {'class':'Icon__container_3u7WK'}))
start_index = string.find('style')
color = string[start_index + 13:start_index + 20]
return color
А теперь напишем парсер, который проходит по списку логинов, для каждого отправляет запрос и собирает всю статистику профиля. Причём если цвет иконки роста зеленый, то в поле year_stats_prefix добавим «+», иначе «-». В конце сделаем паузу на 0.2 секунды на всякий случай, чтобы не напороться на неявные ограничения.
session = requests.Session()
login_list = []
volume_list = []
volume_prefix_list = []
year_stats_list = []
year_stats_prefix_list = []
count = 0
for login in logins:
print(count, '/', len(logins))
try:
count += 1
if login == 'blocked_user':
continue
url = f'https://www.tinkoff.ru/invest/social/profile/{login}'
request = session.get(url, headers=headers)
soup = bs(request.content, 'lxml')
try:
login = soup.find('div', {'class':'ProfileHeader__nickname_1oynx'}).text
volume = soup.find('span', {'class':'Money__money_3_Tn4'}).text
_to = soup.find('div', {'class':'ProfileHeader__statistics_11-DO'}).text.find('До')
_from = soup.find('div', {'class':'ProfileHeader__statistics_11-DO'}).text.find('От')
year_stats = soup.find('div', {'class':'ProfileHeader__statisticsItem_1HPLt'}).text
color = up_or_down_color(soup)
except AttributeError as E:
print(login, E)
continue
volume_list.append(volume)
login_list.append(login)
if _to == -1:
volume_prefix_list.append('до')
else:
volume_prefix_list.append('от')
year_stats_list.append(re.findall(r'\d+.+', year_stats)[0])
if color == '#22a053':
year_stats_prefix_list.append('-')
elif color == '#dd5656':
year_stats_prefix_list.append('+')
else:
year_stats_prefix_list.append('')
except Exception as E:
print(E)
continue
time.sleep(0.2)
Собираем все аккаунты и статистику по ним в DataFrame. Их тоже сохраним себе в базу.
df_users = pd.DataFrame()
df_users['login'] = login_list
df_users['volume_prefix'] = volume_prefix_list
df_users['volume'] = volume_list
df_users['year_stats_prefix'] = year_stats_prefix_list
df_users['year_stats'] = year_stats_list
client.execute("INSERT INTO users VALUES", df_users.to_dict('records'))
А теперь сделаем LEFT JOIN таблицы с постами к таблице с пользователями, чтобы у каждой строки с постом была ещё статистика по аккаунту автора. Запишем результат в DataFrame.
posts_with_users = client.execute('''
SELECT login, post, likes, volume_prefix, volume, year_stats_prefix, year_stats FROM posts
LEFT JOIN users
ON posts.login = users.login
''')
posts_with_users_df = pd.DataFrame(posts_with_users, columns=['login', 'post', 'likes', 'volume_prefix', 'volume', 'year_stats_prefix', 'year_stats'])
Полученный результат будет выглядеть так:

Частотный словарь и биграммы
Для начала составим частотный словарь по постам без разделений на группы.
posts_with_users_df.post.str.split(expand=True).stack().value_counts()
Получим, что предлоги и союзы превалируют над остальными словами:

Значит, их нужно убрать из сета данных. Но даже в том случае, если данные будут очищены, получим что-то подобное. Такие данные ни о чём не говорят, и никаких выводов сделать не удастся.

В таком случае попробуем построить биграммы. Одна биграмма — последовательность из двух элементов, то есть два слова, стоящие рядом друг с другом. Существует много алгоритмов построения n-грамм разной степени оптимизации, мы воспользуемся встроенной функцией в nltk и разберём пример построения биграмм для одной группы. Первым делом импортируем дополнительные библиотеки, загружаем stopwords для русского языка и чистим данные. В список стоп-слов вносим дополнительные: среди них будут и тикеры акций, которые встречаются в каждом посте.
import nltk
from nltk.corpus import stopwords
from pymystem3 import Mystem
from string import punctuation
import unicodedata
import collections
nltk.download("stopwords")
nltk.download('punkt')
russian_stopwords = stopwords.words("russian")
append_stopword = ['это', 'sber', 'акция', 'компания', 'aflt', 'gazp', 'yndx', 'lkoh', 'mail', 'год', 'рынок', 'https', 'млрд', 'руб', 'www', 'кв']
russian_stopwords.extend(append_stopword)
Опишем функцию для подготовки текста, которая переведёт все слова в нижний регистр, приведёт к нормальной форме, удалит стоп-слова и пунктуацию:
mystem = Mystem()
def preprocess_text(text):
tokens = mystem.lemmatize(text.lower())
tokens = [token for token in tokens if token not in russian_stopwords\
and token != " " \
and token.strip() not in punctuation]
text = " ".join(tokens)
return text
posts_with_users_df.post = posts_with_users_df.post.apply(preprocess_text)
Для примера возьмём посты группы инвесторов с объёмом портфеля до 10 тысяч рублей и построим биграммы, а затем выведем самые частые:
up_to_10k_df = posts_with_users_df[(posts_with_users_df['volume_prefix'] == 'от') & (posts_with_users_df['volume'] == '10 000 ₽')]
up_to_10k_counts = collections.Counter()
for sent in up_to_10k_df["post"]:
words = nltk.word_tokenize(sent)
up_to_10k_counts.update(nltk.bigrams(words))
up_to_10k_counts.most_common()
Получаем такой список:

Результаты исследования биграмм
В группе с объёмом портфеля до 10 и 100 тысяч руб. инвесторы чаще пишут о личном опыте и полученной прибыли: на это указывают биграммы «чистая прибыль» и «финансовый результат».
До 10 000 руб:
- добрый утро, 44
- цена нефть, 36
- неквалифицированный инвестор, 36
- чистый прибыль, 32
- шапка профиль, 30
- московский биржа, 30
- совет директор, 28
До 100 000 руб:
- чистый прибыль, 80
- финансовый результат, 67
- добрый утро, 66
- индекс мосбиржа, 63
- цена нефть, 58
- квартал 2020, 42
- мочь становиться 41
В группе до 500 тысяч руб. впервые появляются биграммы со словами «подписываться», «выкладывать», «новость» — инвесторы с такими характеристиками портфеля часто заводят собственные блоги об инвестировании и продвигают их через посты в Пульсе.
До 500 000 руб:
- чистый прибыль, 169
- квартал 2020, 154
- отчетность 3, 113
- выкладывать новость, 113
- 🤝подписываться, 80
- подписываться выкладывать, 80
- публиковать отчёт, 80
- цена нефть, 76
- колво бумага, 69
- вес портфель, 68
В биграммах группы с объёмом портфеля до и от 1 миллиона руб. появляется «фьючерс», что логично — это сложный инструмент, который обычно не рекомендуется новичкам. Кроме того, в постах группы проходит больше обсуждений отчётностей компаний — это биграммы «финансовый отчетность», «опубликовать финансовый», «отчетность мсфо».
До 1 000 000 руб:
- 3 квартал, 183
- квартал 2020, 157
- фьчерс утро, 110
- финансовый отчетность, 107
- опубликовать финансовый, 104
- чистый прибыль, 75
- наш биржа, 72
- отчетность мсфо, 69
- цена нефть, 67
- операционный результат, 61
- ноябрьский фьючерс, 54
- азиатский площадка, 51
От 1 000 000 руб:
- октябрь опубликовывать, 186
- 3 квартал, 168
- квартал 2020, 168
- финансовый отчетность, 159,
- опубликовывать финансовый, 95
- чистый прибыль, 94
- операционный результат, 86
- целевой цена, 74
- опубликовывать операционный, 63
- цена повышать, 60
Если числовые поля (проценты) как `year_stats` хранить, то числом.
А вы пишете в Пульсе?
Спасибо за комментарий! Пульс, нет, не использую.
Добрый день!
Подскажите, пожалуйста, можно ли как то получить перечень подписчиков/перечень сделок для профиля в Пульсе из браузера, а не из мобильного приложения?