Как стать автором
Обновить

Соединяем физику и лирику. Как я собрал рекомендательную систему для стихов с помощью Flask, sqlite-vec и Hugging Face

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров1.1K

Люблю я кодить и стихи —
Вот, в общем, все мои грехи...

А. С. Пушкин

Привет! Я Константин Хабазня, преподаватель программирования и математики, а также автор (что бы это ни значило).

N-нное время назад увлёкся NLP (Natural Language Processing), что вполне логично для писателя, который кодит (или кодера, который пишет).

Почитав интернет и пару вводных книжек, отправился учиться на ДПО в МФТИ. В качестве выпускного проекта придумал себе задачу — создать рекомендательную систему для стихов. Для песен рекомендательные системы есть, для книг — есть, для фильмов — есть, для стихов — нет. Непорядочек. А значит, этому городу нужен герой! Чип и Дейл Обезьянка с Питоном спешат на помощь!

По ходу дальнейшего текста расскажу, как собрал первый прототип системы на небольшом датасете с Kaggle, используя Flask, sqlite-vec, энкодер с Hugging Face и чистый (ну почти) HTML.

0. Как всё работает

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

Берём много стихов (на картинке это Data). С помощью энкодера и библиотеки Sentence-Transformers (BERT-model) превращаем стихи в 512-мерные векторы. Складываем векторы и соответствующие им тексты в векторную базу (Vector base).

Пользователь (User) вводит в систему (App) стихотворение. Модель (BERT-model) на лету превращает его в вектор (стихотворение, а не пользователя). Этот вектор сверяется с векторами базы (Vector base). Система (App) выбирает три наиболее схожих по косинусному расстоянию вектора, берёт соответствующие им тексты (включая исходный, если он есть в базе) и возвращает их пользователю (User) в качестве рекомендаций.

Фух. Вы что-нибудь поняли? Я тоже нет. Давайте разбираться.

1. Датасет

«Content is King» — сказал Билл Гейтс ещё в 1994 году и, кажется, был прав. Датасет — уже полдела, и это не преувеличение.

Данных должно быть МНОГО, они должны быть ЧИСТЫМИ. А ещё их надо ХРАНИТЬ в какой-либо удобной для использования структуре.

Я решил начать с простого. Нашёл на Kaggle датасет:

Классические стихи, небольшой объём — для сборки прототипа годится как нельзя лучше
Классические стихи, небольшой объём — для сборки прототипа годится как нельзя лучше

2. Векторная база

Для машины тексты бесполезны. Душевных порывов она не понимает (пока), и виршами в ресторане под луной (с бокальчиком вина) её не соблазнишь. Тексты надо превратить в векторы, которые будут храниться в векторной базе данных. Вот на векторы машина уже клюнет. Дело пошло.

Методом проб и ошибок остановил свой выбор на библиотеке sqlite-vec. Почему не облачное векторное хранилище?

Во-первых, запрос к сторонним северам удлинил бы время отклика системы. Во-вторых, для первого прототипа не хотелось плодить сущности (в которых ещё нужно разобраться) — чем проще, тем лучше.

Базу собираем в Google Colab. Грузим библиотеки и датасет, инициализируем модель:

!pip install sqlite-vec
import sqlite3
import sqlite_vec
import pandas as pd
import numpy as np
from sentence_transformers import SentenceTransformer
from typing import List

model = SentenceTransformer('distiluse-base-multilingual-cased-v1', device='cpu')

df = pd.read_csv('russianPoetryWithTheme.csv')

Из каждой строки датафрейма вычленяем автора, название, текст, комбинируем в единую строку, генерируем эмбеддинги таких строк:

texts = []
for idx in range(16694):
  author = df.iloc[idx]['author']
  name = df.iloc[idx]['name']
  text = df.iloc[idx]['text']
  texts.append(author + '\n' + str(name) + '\n' + text)

def get_normalized_embeddings(texts: List[str]) -> np.ndarray:
    embeddings = model.encode(texts, convert_to_numpy=True)
    return embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)

embeddings = get_normalized_embeddings(texts)

Инициализируем базу "vectors.db" и создаём в ней таблицу, в которой будут храниться пары «вектор — текст»:

db = sqlite3.connect("vectors.db")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)

db.execute("""
    CREATE VIRTUAL TABLE documents USING vec0(
        embedding float[512],
        text text
    )
""")

Заполняем базу векторами и текстами:

from tqdm.notebook import tqdm
BATCH_SIZE = 1
with db:
    for i in tqdm(range(0, len(texts), BATCH_SIZE)):
        batch = [
            (j+1,
             embeddings[j].tobytes(),
             texts[j])
            for j in range(i, min(i+BATCH_SIZE, len(texts)))
        ]
        db.executemany(
            "INSERT INTO documents(rowid, embedding, text) VALUES (?, ?, ?)",
            batch
        )

Копируем в проект готовый файл "vectors.db".

3. Веб-приложение на Flask

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

Помним: чем проще — тем лучше, тем быстрее разберёмся с кодом, тем меньше швов придётся латать, тем быстрее соберём работающий прототип и получим дофаминчик. Друзья скажут: «ты гений!» Работодатели завалят баксами! (Работодатели, ау, отзовитесь! Перед вами... гений... бэкенда же... вы что... разве... не понимаете... гений... бэкенда...😢)

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

Собираем звездолёт.

Вот так выглядит "main.py".

from flask import Flask, render_template, request
from translation_pipe import translation
from get_vec_db import search_similar_texts

app = Flask(__name__, template_folder='templates')

@app.route("/", methods=['GET'])
def text_form():
    return render_template('index.html')

@app.route("/poe3", methods=['GET', 'POST'])
def similarity_form():
    if request.method == 'GET':
        return render_template('poe3.html')
    elif request.method == 'POST':
        text = request.form.get('textarea')
        results = search_similar_texts(text, top_k=3)
        text_match = []
        for row in results:
            row_list = row[1].split('\n')
            text_match.append([row[2], row_list]) #f"Доля схожести: {row[2]:.4f}, Текст: {row[1]}...")
        return render_template('validation.html', text_match=text_match)

@app.route("/validation", methods=['GET', 'POST'])
def validation_form():
    if request.method == 'GET':
        return render_template('poe3.html')
    elif request.method == 'POST':
        try:
            score = float(request.form.get('validarea'))
        except Exception:
            return render_template('poe3.html', text_valid=['Не сработало. Попробуй оценить снова!'])

        with open('validation.txt', 'r', encoding='utf-8') as f:
            validate_list = list(map(float, f.readlines()))
        validate_list[0] += 3.0
        validate_list[1] += score
        validate_list[2] = (validate_list[1]/validate_list[0])*100
        to_file = str(validate_list[0]) + '\n' + str(validate_list[1]) + '\n' + str(validate_list[2])
        with open('validation.txt', 'w', encoding='utf-8') as f:
            f.write(to_file)
        return render_template('congrats.html', text_valid=['Спасибо за оценку!'])

@app.route("/score", methods=['GET'])
def get_score():
    with open('validation.txt', 'r', encoding='utf-8') as f:
        score_list = list(map(float, f.readlines()))
    return render_template('score.html', score_list=score_list)

if __name__ == "__main__":
    port = 8080
    app.run(debug=True, host='0.0.0.0', port=port)

Он умеет запускать сервер и назначать функции-обработчики любого запросов из браузера.

"/" — дефолтный GET, который показывает стартовую страницу сайта (не имеющую отношения к проекту).

"/poe3" — вызывает страницу с окошком и кнопкой для ввода текста пользователем, а также обрабатывает стихотворение, введённое пользователем: оно отправляется в функцию "search_similar_texts()", которая возвращает три схожих текста. Эту рекомендацию мы закидываем на страницу "validation.html", куда и редиректим пользователя. Он видит выдачу, а также запрос оценить рекомендацию.

"main.py" главный узел системы. Функция "search_similar_texts()" запакована в отдельный модуль "get_vec_db.py", который выглядит так:

import sqlite3
import sqlite_vec
import numpy as np
from sentence_transformers import SentenceTransformer
from typing import List

model = SentenceTransformer('distiluse-base-multilingual-cased-v1', use_auth_token=False)
db = sqlite3.connect('vectors.db', check_same_thread=False)
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)


def search_similar_texts(query_text: str, top_k: int = 5) -> List[tuple]:
    query_embedding = model.encode(query_text, convert_to_numpy=True)
    query_embedding = query_embedding / np.linalg.norm(query_embedding)

    rows = db.execute(
        "SELECT rowid, text, embedding FROM documents"
    ).fetchall()

    res = []

    for r in rows:
        stored_vec = np.frombuffer(r[2], dtype=np.float32)
        similarity = np.dot(query_embedding, stored_vec)
        res.append((r[0], r[1], float(similarity)))

    return sorted(res, key=lambda x: x[2], reverse=True)[:top_k]

Вернёмся к "main.py". А точнее к обработчику запроса "/validation", где мы просим пользователя оценить релевантность ответа системы после выдачи рекомендаций. Здесь остановимся подробнее.

4. Валидация

Хьюстон, у нас проблема: как валидировать релевантность выдачи, если машина равнодушна к виршам под луной и лирика ей до лампочки? Что значит, что система выдала «хорошую» рекомендацию? Каковы критерии? Мы не можем измерить «схожесть» двух стихов машинно. Это работа для человека — вопрос вкусов.

Итак, человеческая валидация. Каждый пользователь может выставить системе максимум 3 балла. Значит, максимальное количество баллов = 3*(количество пользователей, провалидировавших систему).

Однако злые уважаемые пользователи ставят «реальные» баллы: 1, 2, 3. Соответственно, общее «реальное» количество баллов меньше «максимального». Кто в школе учил доли и проценты поймёт, что будет дальше: делим «реальное» количество баллов на «максимальное», умножаем на 100 и получаем процент точности рекомендательной системы.

Процент... провалидировавшие... делим... какое небо сегодня красивое...
Процент... провалидировавшие... делим... какое небо сегодня красивое...

Именно этим и занимается обработчик запроса "/validation". В нём вся описанная выше математика работает в связке с txt-файлом, в котором мы храним текущие показатели баллов. Открываем файл, обновляем баллы, записываем обновлённые баллы в файл, закрываем файл. Приводить его не буду ввиду тривиальности, а также потому что его копия уже давно работает в деплое с текущими показателями баллов.

Их, кстати, можно увидеть по запросу "/score", который тоже обрабатывается нашим "main.py". Обработчик этого запроса открывает txt-файл с баллами и рендерит отчёт на отдельной странице.

Раскидал ссылку на систему по своим соцсетям. На момент написания статьи score такой. Систему оценили 134 человека
Раскидал ссылку на систему по своим соцсетям. На момент написания статьи score такой. Систему оценили 134 человека

Кстати, о страницах.

5. HTML + Jinja2 = ❤️

Jinja2 – серый кардинал происходящего. До настоящего момента он управлял нашим фронтендом, а мы и не замечали. Его вотчина — HTML-шаблоны, в которые можно встраивать Python-код. Именно благодаря такому функционалу обработчики запросов из "main.py" могут отдавать в HTML-страницу структуры данных Python, а те будут корректно с ними работать.

Я понимаю, вы уже устали. Приведу для примера код страницы валидации "score.html", скрин которой мы только что видели:

{% extends "base.html" %}
{% block title %}Страница валидации{% endblock %}

{% block content %}
    <h1>Страница валидации</h1>

    <div>Максимальная оценка: {{ score_list[0] }}</div>
    <br>
    <div>Фактическая оценка: {{ score_list[1] }}</div>
    <br>
    <div>Точность в процентах: {{ score_list[2] }} %</div>
    <br>

{% endblock %}

Смотрите, что происходит. С помощью хитроумного синтаксиса я могу обрабатывать в этом HTML-коде питоновский список — и всё работает.

Ещё один маленький рывочек. Строчка "{% extends "base.html" %}" означает, что шаблон страницы валидации наследует базовому шаблону "base.html", чтобы каждый раз руками не прописывать одни и те же постоянные элементы (например, ссылки на мои соцсети, на которые вы сразу же подпишитесь, потому что вам очень понравилась моя статья, и вообще, я смешной, ведь правда?😊)

<!DOCTYPE html>
<html lang="en, ru">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
    <p></p>
    {% block content %}{% endblock %}
    <br>
    <br>
    <a href="https://t.me/habaznyaclub">откровения технаря о поэзии 📖</a>
    <br>
    <br>
    <a href="https://t.me/habaznya_math">откровения поэта о коде, AI, NLP 🛠</a>
</body>
</html>

Ну всё-всё, не плачьте, мы уже закончили, всё позади. Я же сказал: как комарик укусит. И ничего в итоге сложного — всё просто и понятно, да?

Усталь
Усталь

6. Бонус-трек: деплой на сервер

Есть вещи, которые выходят за рамки нашей истории. Например, работа Flask и Jinja2, синтаксис sqlite-vec и особенности энкодеров Hugging Face. Детали деплоя — одна из таких вещей. В сети куча материалов — гуглите, чатботьте, пишите в поддержку своего хостера.

У моего есть такая классная фича — подгружать деплой из git-репозитория. Делаешь новый push, сервер подхватывает его на лету, деплоит обновления сам. Звучит прекрасно, но, чтобы настроить такой деплой, разобраться с зависимостями и так далее, у меня ушло едва ли не больше времени, чем на сам проект.

В общем, в этом месте вы вспомните все нецензурные слова, которые знаете. Удачи!

7. Наконец-то вывод

Итак, разобравшись с азами фреймворков Flask и Jinja2, почитав документацию sqlite-vec, научившись пользоваться энкодерами из коробки (fine-tuning не делали), потратив определённое время и пару метров нервов на репозиторий, зависимости, деплой, мы собрали собственную рекомендательную систему для стихов. Точность у неё небольшая — 67%, но для первого приближения неплохо.

Анализируя опыт пользователей, понял важную вещь. Эмбеддинг кодирует прежде всего семантику текста, а не стилистику, ритм, созвучия.

Я ввожу первые две строчки стихотворения, которое точно содержится в базе, но не получаю его в выдаче. Или получаю, но не первым. Что это значит? Вектор двух строчек может не вобрать в себя семантику всего стихотворения, поэтому в действительности будет далёк от вектора целого стихотворения.

Речь идёт именно о рекомендательной системе. Целое-к-целому. Как в «Яндекс Музыке». А не поиск по фрагменту, как в «Шазаме».


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

Чем больше в моих каналах будет людей, тем больше все будут думать, что я деловой и умный перец. Но мы-то с вами знаем правду😉

Клац-клац
Клац-клац
Теги:
Хабы:
+3
Комментарии6

Публикации

Работа

Data Scientist
45 вакансий

Ближайшие события