В предыдущей статье цикла мы установили, что современной системе автодополнения нужно машинное обучение — чтобы ранжировать варианты подсказок.
Машине, как и человеку, для обучения нужны данные. Мы используем подход «обучение с учителем». Он предполагает, что мы показываем алгоритму примеры задач, для которых нам заранее известно правильное решение. Алгоритм извлекает из этих примеров закономерности и учится принимать правильные решения даже в тех ситуациях, которых он раньше не видел.
Результат обучения критическим образом зависит от качества и объема этих примеров. И именно здесь у нас возникают трудности.
С одной стороны, собирать базу на основе кода наших пользователей нельзя: результат вашего труда остается только у вас, на серверы JetBrains он не попадает.
С другой стороны, из доступных репозиториев с открытым кодом полноценный датасет собрать не получается. Делая так, мы обучали бы алгоритм работать только с готовым (рабочим) кодом.
Сегодня обсудим, как мы выкручиваемся из этой ситуации. Для начала давайте более подробно рассмотрим, почему не получается работать с готовыми репозиториями.
Опенсорсный датасет
Наивная идея
Как должен выглядеть типичный пример в нашем обучающем множестве? Наверное, это известный нам кусочек кода, в котором полностью или частично стерто одно слово (использование переменной, вызов метода, доступ к полю или что-то подобное), и наша система должна это слово правильно подсказать.
В мире много тысяч проектов с открытым кодом, к нашим услугам миллионы строк на любом языке программирования. Это огромные объемы данных, и кажется, что из них можно нарезать столько кусочков кода, что хватит всем.
Наивный алгоритм создания датасета выглядит как-то так:
while (needMoreData()) {
repository = randomRepository();
file = randomFile(repository);
position = randomPosition(file);
token = getToken(file, position);
// Braces, spaces, punctuation and such are useless
if token.isUseless()
continue;
preparedFile = file.deleteCharacters(position, endOf(token));
generateDataPoint(preparedFile, position, token);
}
Здесь мы удаляем только окончание того токена, который хотим угадывать. Но можно сделать и по-другому, например стереть весь конец файла или метода.
Почему наивная идея не работает
Описанный выше подход выглядит удобно, но, к сожалению, в нем есть недостатки. Он даст нам синтетический набор данных, который отличается от реальных сценариев использования. Принципиальных отличий два.
Процесс разработки софта не исчерпывается историей коммитов
Как правило, разработчик делает коммит, когда код в некотором смысле «готов». Примерные атрибуты «готовности» выглядят так:
Код компилируется (или, в случае интерпретируемых языков, запускается).
Юнит-тесты проходят.
Все отформатировано.
Вы сделали какой-никакой рефакторинг, склеили дублирующиеся блоки, удалили явно недостижимый код.
Вы убрали отладочную печать.
В истории коммитов мы, скорее всего, найдем только такие версии кода, в которых большая часть этих условий выполняется. При этом очевидно, что в процессе работы код почти всегда не такой. Именно с «не таким» кодом нашей системе в основном и предстоит работать.
Из-за разницы между «готовым» и «неготовым» кодом хорошо смоделировать распределение вызовов комплишена довольно непросто. Вызовы методов, обращения к полям, обращения к локальным переменным — в ходе разработки соотношение этих групп совсем другое. Чему мы на таких данных научимся — большой вопрос.
В синтетических данных нет факторов пользовательского поведения
Иногда пользователь выбирает подсказку меньше чем за секунду, а иногда ему нужна пара минут, чтобы подумать.
Это важно учесть? Еще как! Но в синтетическом наборе этот фактор взять неоткуда. Поэтому, чтобы покрыть в датасете все сценарии, необходимо использовать настоящие данные.
Настоящие данные, которые нельзя брать
В идеале нам бы делать снэпшоты кода при каждом вызове окна автодополнения. Если бы у нас были снимки состояния вашего кода в каждый момент времени и мы бы точно знали о каждой выбранной вами подсказке, вот бы мы развернулись! Сделали бы такую систему подсказок, что ух!
Но безопасники вряд ли одобрят.
Где-то тут и возникает вопрос защиты данных. Пользовательский код смотреть нельзя. Соблюдаем ли мы это правило?
Мы не логируем то, что вы пишете в редакторе. На самом деле, хочется остановиться и отдельно рассказать, до какой степени мы этого не делаем.
Все поля в логах, которые мы собираем, — либо перечислимые (enum), либо числовые. Там просто некуда записать текст в свободной форме или бинарник. Мы не сохраняем ни хешей, ни списков библиотек, которые вы используете.
Сервер получает логи из IDE, парсит их и выбрасывает любые данные, которые не может сопоставить с образцом (очень жестким). В логах запрещено все, что не разрешено явно. Мы так сделали для того, чтобы никто из наших разработчиков не смог случайно (и даже специально) записать в логи то, что нам как компании знать не положено.
Мы делаем все возможное и даже чуть-чуть сверх нормы, чтобы ваш код не попал к нам на серверы. Но как бы нам обучить нашу систему автодополнения? Нам нужно где-то взять данные для этого.
В данном случае нам удалось придумать работающее решение и уладить вечный конфликт между безопасностью софта и удобством его использования.
Настоящие данные, которые можно брать
Логи системы автодополнения
Что можно записать в логи, чтобы получить максимально подробное описание вызова окна автодополнения и окружающего кода, но при этом не сохранять сам код? Можно прикинуть, какие факторы мы хотим использовать в обучении, и сохранить только их. Это удобно показать на примере.
Предположим, вы программируете на Python и в данный момент пишете функцию, которая реализует статистический критерий бутстрап (если не знаете, что это такое, — не страшно). Этот критерий попал в мой пример только потому, что мы сами его используем для расчета статистической значимости в A/B-тестах (о которых речь пойдет в одной из следующих статей). Так что я случайно знаю, как его реализовать.
В общем, функция может начинаться как-то так:
def bootstrap(datapoints, samples = 10000, confidence = 0.95):
stats = []
for i in range(samples):
subsample = random.c| ⇐ здесь курсор
По-хорошему, сейчас следует подсказать random.choices
, потому что для бутстрапа нужно создавать выборки с возвратами того же размера, что и исходная, а random.choices
ровно это и делает. Но до того как что-либо показывать, запишем в логи следующие данные:
(enum) Окно вызвано автоматически (а не вручную с помощью
Ctrl + Space
).(число) Уровень отступа — 2. Эта фича вообще полезна, а для Python и вовсе критична.
(число) Длина набранного префикса — 1 символ. Отметим, что сам символ «c» мы нигде не запоминаем.
(enum) Родительский элемент в синтаксическом дереве — цикл.
(enum) Вызов подсказки произошел в середине строки.
(enum) Вызов подсказки произошел после точки.
Эти данные мы логируем для каждого вызова окна автодополнения — еще до того, как у пользователя появляется возможность с ним повзаимодействовать. За следующие 150 миллисекунд (ну, как минимум, нам бы этого очень хотелось) появляется собственно окно. Оно может выглядеть как-то так:
![](https://habrastorage.org/getpro/habr/upload_files/555/d0c/384/555d0c384cdc5eabc52b0d3799f50dc3.png)
Предположим, вам понадобилось чуть больше секунды, чтобы заметить нужный вариант на второй позиции, перевести на него курсор и подтвердить выбор. Мы запомним следующую информацию об этом событии:
(число) Задержка до действия пользователя составила 1100 миллисекунд.
(enum) Действие — выбор из списка (он же «успех»).
(число) Позиция выбранного элемента — 1 (считать начинаем с нуля, прямо как программисты)
Кроме общей информации про это окно, мы хотим записать в логи данные о каждой подсказке из него. Разберем это на примере целевой подсказки random.choices
.
(число) Длина подсказки составляет 7 символов.
(число) Совпадение с префиксом — 1 символ.
(enum) Позиция совпадения — начало подсказки. Для _bisect это была бы середина, поскольку там символ «c» — не первый.
(enum) Подсказка является символом из стандартных библиотек языка.
Конечно, здесь приведен не полный список: мы сохраняем больше информации, но вся она имеет сходную природу. Мы сохраняем очень подробное описание кода, но не сам код.
Забавные, но важные факторы
Сравнение силы факторов — важный аспект машинного обучения. Мы, конечно, тоже этим занимаемся. Среди самых значимых фичей есть пара курьезных.
Эвристики
(число) Позиция подсказки в соответствии с эвристиками.
Оказывается, совсем выбрасывать ранжирование правилами — невыгодно. В прошлом мы пытались убрать правила, но при этом терялась группировка по типу подсказки (ключевое слово, переменная, метод и т. д.) В результате получался подобный винегрет:
![](https://habrastorage.org/getpro/habr/upload_files/0d7/848/9e9/0d78489e98c54b206bd41eefb3fea3c4.png)
Перемешанные подсказки выглядят неаккуратно и не нравятся пользователям. Поэтому мы берем за основу ранжирование, полученное с помощью эвристик, и применяем машинно обученное улучшение поверх него.
Мы поднимаем релевантные с нашей точки зрения элементы только на верхние пять позиций. Все, что ниже, сохраняет группировку по типам. Внутри каждой такой группы мы ранжируем подсказки с помощью наших формул. Если нам не удастся поднять нужную подсказку наверх, ее, по крайней мере, будет легче искать по типу.
Кроме того, даже для верхних пяти позиций результат эвристик — все еще очень сильный фактор, который мы используем наравне с остальными.
D.R.Y.
Хорошие разработчики всегда следуют принципу «не повторяться» («Don’t Repeat Yourself») и добросовестно рефакторят свой код. Все блоки, которые приходится писать по два раза, они выделяют в функции, чтобы код был понятнее и не провоцировал ошибки.
Каждый рад был бы узнать в этом описании себя, но нет. То ли разработчики не все хорошие, то ли D.R.Y. — ерунда, но факторы, связанные с повторами в коде, — одни из самых сильных:
(enum) Слово, которое мы подсказываем, уже встречается в файле.
(enum) Слово, которое мы подсказываем, уже встречается в файле в похожем контексте.
Что такое «похожий контекст»?
Мы сравниваем n-граммы из символов языка программирования. Имя переменной, вызов метода или оператор являются в этих n-граммах буквами. Если в некотором файле есть фрагмент кода, в котором встречаются такие же операторы, переменные и вызовы, как рядом с курсором, то считается, что в этом фрагменте кода похожий контекст. То, что стоит на месте курсора в этом похожем фрагменте, — с огромной долей вероятности и есть искомая подсказка.
Чем более фрагменты похожи, тем больше шансов, что и токен, который надо подсказать, совпадает. И чем чаще вы использовали какое-то имя в файле, тем больше шансов, что вы его опять используете.
В общем, разработчики пишут похожие блоки кода снова и снова. Мы, конечно, всегда это подозревали, но теперь знаем наверняка. Остается утешаться тем, что такое поведение пользователей облегчает реализацию автодополнения.
На этих данных вообще можно что-то обучить?
Данные, что у нас получились, больше всего напоминают соревнования по машинному обучению типа Kaggle. Там участникам могут выдать полностью анонимные данные, состоящие из наборов цифр, а что какая цифра значит — не пояснить.
Нам чуть проще. Мы хотя бы знаем, откуда цифры взялись и что они означают, но исходника, из которого они получены, у нас нет. Если какой-то пользователь ведет себя нетипично (например, очень редко выбирает что-то в окне подсказок), мы не сможем посмотреть его код и подсказки, чтобы понять, почему это происходит. В таких случаях приходится проводить целое расследование, опираясь на косвенные улики, и не все загадки удается разгадать.
Тем не менее, работающее ранжирование подсказок нам сделать удалось. Как мы учимся, какие целевые метрики выбираем и почему, какими бывают расследования — расскажем в следующем эпизоде.