[Обновление от 11.02.2021: $ CRSR снизилась на 1%, $ ELY снизилась на 3,04%, $ OSTK выросла на 6,77%, $ APHA снизилась на 35,82% (коррекция?), $ TLRY снизилась на 49,68%, $ RIG снизилась на 5,21% , $ КОГДА на 5% меньше. $ MGNI выросла на 7,22%, а NVTA снизилась на 1,64%. Обнаружен новый тикер: VFF.]

[Обновление от 10.02.2021: $ CRSR продолжает падать на 4,54%, $ ELY - на 0,73%, $ OSTK - на 2,61%, $ APHA - на 10,74%, $ TLRY - на 51%, $ RIG - на 1,96% и $ КОГДА еще на 68% выше. У $ MGNI также не получилось, снизившись на 6,6%. Обнаружен новый тикер: $ NVTA]

[Обновление от 09.02.2021: $ CRSR сегодня был странным, упав на 2,8%, несмотря на то, что прибыль превысила оценки. $ ELY также снизился на 1,1%, $ OSTK вырос на 4,28%, $ APHA вырос на 25,13% (!!), $ TLRY вырос на 40,74% (!!!), $ RIG снизился на 1,65%, а $ WHEN вырос на… 59% (!! !!) Обнаружена одна новая акция, $ MGNI.]

[Обновление от 08.02.2021: Чувак, какой сегодня понедельник! $ CRSR выросла на 1,68%, $ ELY - на 3,12%, $ OSTK - на 7,29%, $ APHA - на 13,86%, $ TLRY - на 16,99% и $ RIG - на 3,7%. Однако новых тикеров не обнаружено. Я также заметил, что первый тикер, который я когда-либо идентифицировал (и проигнорировал, так как это розовый промах), $ WHEN, поднялся на 37,5% с тех пор, как я его обнаружил. Алгоритм действительно превзошел мои ожидания, поскольку он был создан только в качестве игрушечного примера!]

[Обновление от 05.02.2021: $ CRSR выросла на 56 б.п., $ ELY - на 59 б.п., а $ OSTK - на 4,59%. Обнаружены новые тикеры: $ APHA, $ TLRY и $ RIG.]

[Обновление от 04.02.2021: $ CRSR сегодня выросла еще на 5% (17% за два дня!), $ ELY выросла на 3,8%, а $ OSTK упала на 2,36%. Сегодня алгоритм ничего не уловил.]

[Обновление от 03.02.2021: $ CRSR вырос на 11,88% за один день, неплохо. Алгоритм также взял $ ELY и $ OSTK из сегодняшних заявок, посмотрим, как они работают]

[Обновление 02.02.2021: алгоритм взял $ CRSR из новых представленных материалов, посмотрим, как он будет работать в ближайшие недели]

История о том, как пользователи Reddit победили хедж-фонды Уолл-стрит с помощью акций GameStop (GME), за последнюю неделю приобрела огромную популярность. GME - не единственная акция, от которой без ума Reddit: AMC, BB, NOK и совсем недавно SLV получили невероятный прирост за считанные дни. Эта статья призвана предоставить игрушечный пример того, как анализировать прошлые представления Reddit (в частности, на подреддите «wallstreetbets») и обучать алгоритм классификации для выявления упоминаний акций в новых сообщениях, которые могут стать вирусными в будущем.

Обзор

Весь конвейер включает в себя следующие этапы:

  • Сбор и предварительная обработка данных
  • Построение алгоритма
  • Обучение и настройка гиперпараметров
  • Прогноз

Алгоритмы, которые мы будем использовать для этого игрушечного примера, относительно просты (персептрон и стохастический градиентный спуск), поэтому мы не будем использовать для них какие-либо существующие библиотеки (например, scikit-learn). В любом случае, веселее строить их с нуля. Но мы будем использовать PRAW для очистки и сбора данных с Reddit, для получения дополнительной информации о PRAW и о том, как ее использовать, ознакомьтесь с этой отличной статьей здесь.

Сбор и предварительная обработка данных

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

  1. мы создаем метку для «вирусного» на основе атрибута «оценка» или количества голосов за каждую заявку (1, если оценка> x, или -1 в противном случае, где x - некоторый порог).
  2. мы предполагаем, что станет ли представление вирусным в какой-то момент, можно предсказать, посмотрев на его название, автора, чутье, содержание и самые популярные комментарии.

С этими двумя предположениями наша коллекция данных должна извлекать данные отправки, которые содержат атрибуты «оценка», а также «название», «автор», «чутье», «содержание» и «комментарий» (возможные kwargs для добавления при вызове функции включают limit и time_filter, подробности см. в документации PRAW).

# establish API access to Reddit through praw
client_id = os.getenv("CLIENT_ID")
client_secret = os.getenv("CLIENT_SECRET")
user_agent = os.getenv("USER_AGENT")
wsb = praw.Reddit(
    client_id=client_id, client_secret=client_secret, user_agent=user_agent
).subreddit("wallstreetbets")
# download data for analysis
def download_data(ranked_by="hot", **kwargs):
    """
    ranked_by: str, either hot or top or new
    """
    submissions = []
    if ranked_by == "hot":
        response = wsb.hot(**kwargs)
    elif ranked_by == "top":
        response = wsb.top(**kwargs)
    elif ranked_by == "new":
        response = wsb.new(**kwargs)
    else:
        raise ValueError("ranked_by must be either hot or top or new")

    for submission in response:
        post = []
        post = [
            submission.title,
            submission.link_flair_text,
            submission.selftext,
            submission.created_utc,
            submission.score,
        ]
        try:
            author = submission.author.name
        except AttributeError:
            author = ""
        post.append(author)
        # extract comments from commentforest
        submission.comments.replace_more(limit=0)
        comment = ""
        for top_comment in submission.comments[:100]:
            comment = str(top_comment.body) + ""
        post.append(comment)
        submissions.append(post)
    df = pd.DataFrame(
        submissions,
        columns=[
            "title",
            "flair",
            "content",
            "created_on",
            "score",
            "author",
            "comment",
        ],
    )
    df["created_on"] = pd.to_datetime(df["created_on"], dayfirst=True, unit="s")
    fp = "data/{}_subs.csv".format(ranked_by)
    try:
        os.makedirs(os.path.split(fp)[0])
    except FileExistsError:
        pass

    df.to_csv(fp)

Затем мы предварительно обработаем фрейм данных, заполнив NA, создадим наши метки с заданным порогом и объединим все пояснительные атрибуты в одну строку. Здесь я выбрал порог в 50 000, потому что мой первоначальный исследовательский анализ показывает, что из примерно 2000 заявок только 10% имеют более 50 000 голосов. Идеальный порог должен быть достаточно большим, чтобы различать отправленные материалы, но при этом оставлять хотя бы некоторые данные с положительными отметками. Позже мы поиграем с этим порогом.

# pre-process data
def pre_processing(fp, threshold=50000):
    df = pd.read_csv(fp)         # read in from csv
    df = df.fillna(value="")     # pad NAs with empty string
    df["label"] = [
        1 if df["score"][i] > threshold else -1 for i in range(df.shape[0])
    ]
    df["combined"] = (
        df["title"]
        + " "
        + df["flair"]
        + " "
        + df["author"]
        + " "
        + df["content"]
        + " "
        + df["comment"]
    )
    return df

Я загрузил 1000 самых популярных представлений, а также 1000 лучших представлений и объединил их в один набор данных, чтобы увеличить размер набора данных (Reddit и PRAW, похоже, имеют ограничение в 1000 для своего API, для заинтересованных людей Pushshift потенциально может обойти это вопрос, но мы не будем здесь затрагивать эту тему). Для людей, экспериментирующих с удалением, не стесняйтесь использовать меньший лимит, чтобы избежать времени ожидания.

# download top 1000 hot and top 1000 score submission via API and combined them into one dataframe for learning
download_data('hot', limit=1000)
download_data('top', limit=1000, time_filter='month')
hot_subs = pre_processing("data/hot_subs.csv")
top_subs = pre_processing("data/top_subs.csv")
total_subs = hot_subs.append(top_subs, ignore_index=True).drop_duplicates()

Объединенные данные с shape (1896, 9) выглядят примерно так:

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

def split_dataset(df):
    # split entire dataset into 80% training and 20% validation
    train = df.sample(frac=0.8, random_state=100)
    val = df.drop(train.index)
    train.to_csv("data/train.csv")
    val.to_csv("data/val.csv")
# split dataset into training and validation set
split_dataset(total_subs)

Построение алгоритмов, разработка функций и обучение

Рассматриваемая проблема прогнозирования - это задача линейной классификации. Алгоритм персептрона и стохастический градиентный спуск широко используются в области линейной классификации. В этом примере мы рассмотрим и сравним производительность алгоритмов Perceptron и Pegasos (Primal Estimated sub-GrAdient SOlver for SVM).

Построение векторов признаков

Простое выражение проблемы классификации - найти лучшие тэта и тэта_0, так что тэта * х + тэта_0 = у истинно для максимально возможного числа х. В этом случае x - это вектор нашей функции, а y - наши метки. Мы уже пометили наши данные на этапе предварительной обработки, но на самом деле еще не построили наши векторы признаков.

Поскольку мы предполагаем, что будущую популярность заявки можно предсказать по ее названию, автору, чутью, содержанию и популярным комментариям, одним из простых и естественных способов построения нашего вектора характеристик является подход «мешка слов», в котором мы перебираем все слова во всем наборе данных и представляют наш объединенный текст как вектор формы (n, m), где n - количество представленных материалов, m - общее количество слов, а для каждой строки (или каждого представления) значение равно 1, если слово существует в отправке, или 0 в противном случае.

def bag_of_words(texts, remove_stopword=True):
    """
    Inputs a list of string submissions
    Returns a dictionary of unique unigrams occurring over the input

    """
    stopword = set()
    if remove_stopword:
        with open("data/stopwords.txt") as fp:
            for line in fp:
                word = line.strip()
                stopword.add(word)

    dictionary = {}  # maps word to unique index
    for text in texts:
        word_list = extract_words(text)
        for word in word_list:
            if word not in dictionary:
                if word in stopword:
                    continue
                dictionary[word] = len(dictionary)

    return dictionary


def extract_bow_feature_vectors(submissions, dictionary, binarize=True):
    """
    Inputs a list of string submissions
    Inputs the dictionary of words as given by bag_of_words
    Returns the bag-of-words feature matrix representation of the data.
    The returned matrix is of shape (n, m), where n is the number of submissions
    and m the total number of entries in the dictionary.
    """

    num_submissions = len(submissions)
    feature_matrix = np.zeros([num_submissions, len(dictionary)], dtype=np.float64)

    for i, text in enumerate(submissions):
        word_list = extract_words(text)
        for word in word_list:
            if word in dictionary:
                if binarize:
                    feature_matrix[i, dictionary[word]] = 1
                else:
                    feature_matrix[i, dictionary[word]] += 1
    return feature_matrix

Персептрон

Когда наш вектор признаков и метка готовы, мы можем продолжить работу с алгоритмом Perceptron следующим образом:

Интуиция довольно проста: если точка классифицирована правильно, предсказанное значение theta * x + theta_0 и метка y должны иметь один и тот же знак, а если точка классифицирована неправильно, два значения будут иметь противоположные знаки, и их произведение будет быть меньше 0. Итак, мы обновляем параметры theta и theta_0 соответственно.

def perceptron_single_step_update(
    feature_vector, label, current_theta, current_theta_0
):
    if label * (np.dot(current_theta, feature_vector) + current_theta_0) <= 0:
        current_theta += label * feature_vector
        current_theta_0 += label
    return (current_theta, current_theta_0)

Perceptron неплохо справляется с подгонкой обучающих данных, потому что он использует ошибку обучения для обновления и исправления своих параметров. Однако это может привести к переобучению, и часто точность набора для валидации будет отражать это. Алгоритм Pegasos может несколько смягчить эту проблему.

Пегасы

Pegasos рассматривает проблемы классификации как проблему оптимизации, когда мы пытаемся минимизировать некоторую целевую функцию J, или более формально:

Первый элемент называется потерей шарнира, а второй элемент называется термом регуляризации, где лямбда является параметром регуляризации. Идея состоит в том, что чем больше параметр регуляризации, тем они более «регуляризованы» и, следовательно, меньше переоснащаются, но в то же время приводит к потенциально более высоким потерям из-за ошибок обучения. Баланс между ошибкой обучения и переобучением может быть достигнут путем настройки параметров лямбда.

Стохастический градиентный спуск

Поскольку мы превратили проблему в проблему оптимизации, все, что нам нужно сделать, это найти theta и theta_0, которые минимизируют J. Мы используем стохастический градиентный спуск (SGD) следующим образом:

Идея градиентного спуска заключается в том, что мы обновляем наши параметры, перемещаясь против направления его наклона на величину, которая в точности равна его производной в этой точке, пока не достигнет минимума, а стохастический градиентный спуск - это просто более эффективный с вычислительной точки зрения метод с помощью выборки. обучающие данные случайным образом и медленно подталкивают параметры в нужном направлении, а не применяют градиентный спуск ко всей целевой функции. Параметр eta называется скоростью обучения и предназначен для уменьшения случайности, которую мы ввели в SGD. Простая форма эта - 1 / (1 + T).

Следует отметить, что Pegasos обновляет параметры независимо от того, точна ли классификация, это отличается от Pecerptron.

def pegasos_single_step_update(
    feature_vector, label, L, eta, current_theta, current_theta_0
):
    """
    Properly updates the classification parameter, theta and theta_0, on a
    single step of the Pegasos algorithm

    Args:
        feature_vector - A numpy array describing a single data point.
        label - The correct classification of the feature vector.
        L - The lamba value being used to update the parameters.
        eta - Learning rate to update parameters.
        current_theta - The current theta being used by the Pegasos
            algorithm before this update.
        current_theta_0 - The current theta_0 being used by the
            Pegasos algorithm before this update.

    Returns: A tuple where the first element is a numpy array with the value of
    theta after the current update has completed and the second element is a
    real valued number with the value of theta_0 after the current updated has
    completed.
    """
    f = 1 - (eta * L)
    if label * (np.dot(feature_vector, current_theta) + current_theta_0) <= 1:
        return (
            (f * current_theta) + (eta * label * feature_vector),
            (current_theta_0) + (eta * label),
        )
    return (f * current_theta, current_theta_0)

Обучение и настройка гиперпараметров

Результаты обучения и проверки для Perceptron и Pegasos следующие:

Как мы видим, Perceptron достиг 100% точности в обучающем наборе, опять же в соответствии с нашими ожиданиями, поскольку он учитывает только ошибку обучения. Мы также можем заметить, что производительность немного упала после ее применения к набору проверки, что снова указывает на проблему переобучения.

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

Мы можем продолжить настройку гиперпараметра T для Perceptron и T и лямбда для Pegasos, вместо того, чтобы выполнять поиск по сетке, я для простоты выполнил настройку только по ряду значений:

Лучше всего работает алгоритм Pegasos с T = 5 и лямбда = 0,001 с наивысшей точностью проверки 0,9156. Затем мы можем повторно обучить алгоритм с этими гиперпараметрами и продолжить наш прогноз с полученными значениями theta и theta_0.

Прогноз

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

Сначала я загружаю 1000 новейших сообщений с wallstreetbets и применяю к ним ту же предварительную обработку, но также удаляю их метки из фрейма данных (поскольку мы будем их предсказывать!), А затем я просто делаю свой прогноз для каждого из них.

for i in range(nsamples):
    feature_vector = feature_matrix[i]
    prediction = np.dot(theta, feature_vector) + theta_0
    if prediction > 0:
        predictions[i] = 1
    else:
        predictions[i] = -1

Наконец, мы находим те материалы, у которых есть положительные метки, извлекаем из них любое упоминание символа акций и распечатываем результаты:

promising_posts = np.array(test_texts)[promising_ids]
for post in promising_posts:
    words = post.split()
    promising_tickers = (list(set(filter(lambda word: word.lower().startswith('$'), words))))
    if len(promising_tickers) > 0:
        print('promising tickers:', promising_tickers)

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

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

Одно из объяснений этого состоит в том, что мы установили слишком высокий вирусный порог (или, другими словами, только AMC и NOK будут считаться подходящими). Однако, если мы уменьшим наш порог в самом начале с 50 000 до 25 000, мы увидим кое-что интересное:

Помимо обычных GME, AMC, TSLA и NOK, есть этот странный биржевой символ $, КОГДА был обнаружен алгоритмом, я быстро взглянул, и это странная акция розового цвета, которая сейчас стоит $ 0,0018 за акцию, по сравнению с $ 0,0008. неделю назад, что-то к северу от 100% за несколько дней. Стиль Reddit? да. Стечение обстоятельств? Наверное, учитывая, что это розовая комбинация и все такое, но посмотреть было довольно интересно.

Заключение

Это краткое изложение - упрощенный пример того, как можно использовать методы машинного обучения для прогнозирования представлений Reddit. Для меня это забавное упражнение, и я надеюсь, что вам тоже понравилось читать о нем. Тем не менее, еще многое предстоит сделать, в том числе использовать Pushshift для значительного увеличения размера нашей выборки и преобразовать код в надежный атомарный конвейер (например, Luigi) для простоты развертывания.

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

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