За два года с момента релиза GPT-3 эту языковую модель использовали в множестве интересных задач — например, для сочинения поэзии, написания футурологических эссе и подготовки научных статей. Но как алгоритм обработки естественного языка может быть полезен программистам?

На этот вопрос в своей новой статье отвечает британский разработчик Саймон Уиллисон* — директор по архитектуре Eventbrite и один из создателей веб-фреймворка Django. Среди различных вариантов применения языковой модели GPT-3 Уиллисон особенно подчеркивает ее способность объяснять, что делает код. По словам специалиста, в этом GPT-3 поразительно эффективна, поскольку явно обучалась на огромном количестве исходного кода.

Под катом — наш перевод материала, в котором автор демонстрирует недавние примеры из своей практики: объяснение кода на Python, JavaScript, SQL, а также работу в рамках GPT-3 с математическими формулами.

*Обращаем ваше внимание, что позиция автора не всегда может совпадать с мнением МойОфис.


Объясняя Python

На днях разработчик Нэд Бэтчелдер поделился прекрасным фрагментом кода:

import re

TOKEN_RX = r"""(?xm)
    (?P<string> ".*?"|'.*?'             )| 
    (?P<float>  \d*(\d\.|\.\d)\d*       )|
    (?P<int>    \d+                     )|
    (?P<id>     [_a-zA-Z][_a-zA-Z0-9]*  )|
    (?P<punct>  [(){}:\[\]=.,+*/-]      )|
    (           \#.*$                   )|
    (           \s+                     )
    """

def tokens(text):
    for match in re.finditer(TOKEN_RX, text):
        if match.lastgroup:
            yield (match.lastgroup, match[0])

TEXT = """
    x = 123 + "hello #99"  # ignore me!
    print(hello.bye[0] + 3.14, 'single')
    """

for kind, text in tokens(TEXT):
    print(f"{kind:7}: {text=}")

Я добавил код в GTP-3 (вот инструкция к песочнице) и начал задавать ей вопросы.

При этом я использовал функцию GTP-3, которая позволяет после выдачи ответа ввести новый текст и в итоге получить дополнительные пояснения.

Все, что выделено ниже жирным шрифтом, — это ответы GPT-3. Остальное — мой текст.

Объясни этот код:

import re

TOKEN_RX = r"""(?xm)
    (?P<string> ".*?"|'.*?'             )| 
    (?P<float>  \d*(\d\.|\.\d)\d*       )|
    (?P<int>    \d+                     )|
    (?P<id>     [_a-zA-Z][_a-zA-Z0-9]*  )|
    (?P<punct>  [(){}:\[\]=.,+*/-]      )|
    (           \#.*$                   )|
    (           \s+                     )
    """

def tokens(text):
    for match in re.finditer(TOKEN_RX, text):
        if match.lastgroup:
            yield (match.lastgroup, match[0])

TEXT = """
    x = 123 + "hello #99"  # ignore me!
    print(hello.bye[0] + 3.14, 'single')
    """

for kind, text in tokens(TEXT):
    print(f"{kind:7}: {text=}") 

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

Объясни регулярное выражение:

Регулярное выражение состоит из нескольких разных групп, каждая из которых соответствует определенному типу токена. Первая группа, «string», соответствует любому тексту, заключенному в двойные или одинарные кавычки. Вторая группа, «float», соответствует любому тексту, который похож на число с плавающей запятой. Третья группа, «int», соответствует любому тексту, который выглядит как целое число. Четвертая группа, «id», соответствует любому тексту, похожему на имя переменной. Пятая группа, «punct», соответствует любому тексту, похожему на пунктуацию. Шестая группа «#.*$» соответствует любому тексту, похожему на комментарий. Седьмая группа, «\s+», соответствует любому тексту, который выглядит как пробел.

Что делает (?xm)?

Нотация (?xm) — это модификатор, который позволяет регулярному выражению размещаться на нескольких строках и содержать комментарии.

Почему он использует match.lastgroup?

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

Итак, мы получили отличное, четкое объяснение. Но я и так знал, как работает этот код. Попробуем теперь разобраться в коде, который я не понимаю.

Объясняя JavaScript

Разработчик Крис Хейнс опубликовал блестящую демонстрацию того, как новый элемент Web Component Declarative Shadow DOM, <slot>, (описан здесь), можно использовать для создания страницы, которая передает контент с сервера, заполняя различные области страницы по мере поступления контента — без использования JavaScript!

Демо доступно здесь; у меня оно запускается только в Chrome, для других браузеров существует версия с полифиллом.

Код полифилла можно увидеть на этой странице. Я не понимал, как он работает, и спросил у GPT-3.

Объясни этот код:

class Enamel extends HTMLElement {
  attemptPolyfillDSD() {
    const dsd = this.querySelector('template[shadowroot]');

    if (dsd?.content) {
      const mode = dsd.getAttribute('shadowroot');
      this.attachShadow({ mode });
      this.shadowRoot.appendChild(dsd.content);

      dsd.remove();

      return true;
    }

    return false;
  }

  connectedCallback() {
    if (
      !HTMLTemplateElement.prototype.hasOwnProperty('shadowRoot') &&
      !this.attemptPolyfillDSD()
    ) {
      const _observer = new MutationObserver(() => {
        if (this.attemptPolyfillDSD()) {
          _observer.disconnect();
        }
      });

      _observer.observe(this, {
        childList: true,
      });
    }
  }
}

export default Enamel;

Этот код подменяет API Shadow DOM для старых браузеров, которые его не поддерживают.

Что ж, звучит убедительно, но информации недостаточно. Дополняю вопрос:

Как он делает это?

В коде используется MutationObserver для отслеживания изменений дочерних элементов элемента. Когда добавляется новый дочерний элемент, код проверяет, является ли он элементом <template> с атрибутом «shadowroot». Если это так, код создает Shadow Root для элемента и добавляет к нему содержимое шаблона. Наконец, код удаляет элемент шаблона из DOM.

Отличное объяснение! Прочитав это и вернувшись к коду, я полностью понял, как он работает.

Создавая SQL

Я хотел построить SQL-запрос, который возвращал бы все строки в одной таблице, которые не были представлены в другой таблице... но где строки использовали бы составной первичный ключ, охватывающий два столбца. Я не знал правильного синтаксиса для этой задачи.

Для начала мне нужно было создать таблицу с составным первичным ключом. Я сразу сделал запрос в GPT-3:

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

CREATE TABLE pages ( type TEXT, id TEXT, content TEXT, PRIMARY KEY (type, id) );

Кажется, система в курсе синтаксиса SQLite SQL, что удобно. Оказывается, вы создаете составной первичный ключ, используя PRIMARY KEY (col1, col2) в конце определения таблицы.

Я добавил вторую строку с моей версией CREATE TABLE, а затем попросил построить мне запрос:

CREATE TABLE pages_done ( type TEXT, id TEXT, PRIMARY KEY (type, id) );

SQL-запрос, возвращающий строки в таблице страниц, для которых нет соответствующих записей в таблице pages_done:

SELECT type, id, content FROM pages WHERE (type, id) NOT IN (SELECT type, id FROM pages_done)

Полезный ответ! Я думал, здесь подойдет шаблон WHERE ... NOT IN (SELECT , но сомневался в синтаксисе для поиска двух столбцов одновременно.

Я был почти уверен, что вместо этого для решения задачи можно также использовать join, и поэтому спросил GPT-3:

Запрос, но с использованием join.

SELECT p.type, p.id, p.content FROM pages p LEFT JOIN pages_done pd ON p.type = pd.type AND p.id = pd.id WHERE pd.type IS NULL;

Но какой вариант лучше? Я задал еще пару вопросов:

Какой из этих двух запросов более эффективен и почему?

Первый запрос более эффективен, поскольку в нем используется WHERE с подзапросом. Второй запрос менее эффективен, поскольку использует LEFT JOIN.

Почему подзапрос более эффективен?

Подзапрос более эффективен, поскольку он возвращает только те строки, которые соответствуют условиям, указанным в пункте WHERE. LEFT JOIN возвращает все строки из левой таблицы, даже если в правой таблице нет совпадений.

Как и до этого, ответ GPT-3 звучит вполне убедительно. Но так ли все обстоит на самом деле? В моем представлении оптимизатор SQL-запросов достаточно умен, чтобы оптимизировать LEFT JOIN и сделать его эффективным. Подозреваю, что по данному вопросу модель вводит меня в заблуждение.

И это неудивительно — по факту GPT-3 вообще ни о чем не знает. Это огромный генератор шаблонного текста. Вы не можете доверять ничему из того, что он вам сообщает, поскольку вся его задача – группировать слова в убедительно выглядящие последовательности, которые построены с учетом ранее загруженного в языковую модель текста.

Иными словами, нейросеть — превосходный источник пищи для размышлений, но для того, чтобы получать от нее реальную пользу, требуются очень глубокие знания: как в непосредственно исследуемом предмете, так и в принципах действия инструментов ИИ.

Наводящие вопросы

Получил отличный совет от data scientist Райли Гудсайда:

Когда вы даете системе подсказки, сложно избегать наводящих вопросов. Если вы спросите, какой из двух запросов более эффективен и почему, крайне маловероятно, что вы получите ответ: «Это не имеет значения», насколько бы потенциально верным не был такой ответ.

Я не учитывал этот момент. Исправлюсь в своих будущих экспериментах.

Бонус: как насчет математики?

Мне стало интересно: можно ли объяснить с помощью GPT-3 математические уравнения?

Недавно GitHub добавил рендеринг LaTeX в свою реализацию Markdown. Я взял случайную формулу с этой страницы документации.

Объясни это:

$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$

Это неравенство Коши-Шварца.

Что ж, теперь мы знаем название. Но как это работает?

Уточняю вопрос: Объясни пошагово синтаксис формулы, обособив каждую ее часть с помощью $$ (необходимость этого условия раскрою ниже):

$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$

Это неравенство Коши-Шварца.

$$\left( \sum_{k=1}^n a_k b_k \right)^2$$

Это квадрат суммы всех произведений соответствующих элементов векторов a и b.

$$\leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$

Это означает, что эта сумма меньше или равна произведению сумм квадратов каждого вектора.

Вот зачем в запросе понадобился $$: благодаря этому ответ был выдан в допустимом синтаксисе GitHub Markdown. Вот отрендеренная версия моей «беседы» с нейросетью:

Объясняя файлы конфигурации

Вот еще один весьма удачный, на мой взгляд, шаблон наводящего вопроса, который я нашел: «Объясни каждую строку этого файла конфигурации:». Я опробовал этот вопрос на примере Lit из стартовых шаблонов Vite 3.0:

Объясни каждую строку этого файла конфигурации:

import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: {
      entry: 'src/my-element.js',
      formats: ['es']
    },
    rollupOptions: {
      external: /^lit/
    }
  }
})

Этот файл конфигурации написан на JavaScript с использованием библиотеки ViteJS. Первая строка импортирует функцию «defineConfig» из библиотеки ViteJS. Эта функция позволяет пользователю определить объект конфигурации для процесса сборки.

Следующая строка экспортирует функцию «defineConfig» в качестве экспорта по умолчанию. Это означает, что при импорте этого файла будет импортирована только функция «defineConfig».

Функция «defineConfig» принимает один аргумент, который является объектом. Этот объект содержит конфигурацию процесса сборки. Свойство «build» содержит конфигурацию инструмента сборки. Свойство «lib» содержит конфигурацию библиотеки, которая будет собрана. Свойство «entry» содержит путь к входному файлу для библиотеки. Свойство «formats» содержит форматы, в которые будет встроена библиотека. В этом случае библиотека будет построена только в формате «es».

Свойство «rollupOptions» содержит конфигурацию инструмента Rollup. Свойство external содержит регулярное выражение, соответствующее именам зависимостей, которые следует исключить из сборки. В этом случае из сборки будут исключены все зависимости, имена которых начинаются с «lit».


Будем рады узнать в комментариях ваше мнение об использовании GPT-3 для объяснения кода и решения других нестандартных задач. Может быть, вы нашли языковой модели другое эффективное применение? А чтобы получать больше интересного контента, подписывайтесь на наш хабр-блог: мы регулярно переводим статьи зарубежных авторов и делимся экспертизой наших специалистов.

Комментарии (7)


  1. OBIEESupport
    28.07.2022 13:55
    +10

    Спасибо за статью. Проверил, на некоторых примерах нужен интеллект похлеще искусственного, чтобы понять, что же оно(она?он?) отвечает.


    1. sebres
      28.07.2022 19:54
      +4

      Притом что врёт местами безбожно. Например в случае sqlite про скорость оно всё с точностью наоборот. Т.к. sqlite не умеет в оптимизацию NOT IN () чтобы юзать кортёжный индекс (type,id), т. е. получим fullscan со всеми вытекающими.
      Более того не просто соврамши - а конкретно так, ибо big-O в случае NOT IN () сильно так нелинейно будет.

      Прув на Tcl (просто мерить сподручнее и быстрее)
      % sqlite3 db :memory:
      % db eval "CREATE TABLE pages ( type TEXT, id TEXT, content TEXT, PRIMARY KEY (type, id) ); CREATE TABLE pages_done ( type TEXT, id TEXT, PRIMARY KEY (type, id) );"
      % set i 0; while {$i < 10000} {incr i; set t "type$i"; set c "content $i"; db eval {insert into pages values(:t, :i, :c)}; if {$i & 1} {db eval {insert into pages_done values(:t, :i)}}}
      % db eval {select (select count(*) from pages), (select count(*) from pages_done)}
      10000 5000
      % timerate {db eval {SELECT p.type, p.id, p.content FROM pages p LEFT JOIN pages_done pd ON p.type = pd.type AND p.id = pd.id WHERE pd.type IS NULL LIMIT 4999,1}}
      4185.05 µs/# 239 # 238.95 #/sec 1000.226 net-ms
      % timerate {db eval {SELECT type, id, content FROM pages WHERE (type, id) NOT IN (SELECT type, id FROM pages_done) LIMIT 4999,1}}
      1520435 µs/# 1 # 0.658 #/sec 1520.435 net-ms

      Для случая IN () AI был бы прав:

      % timerate {db eval {SELECT p.type, p.id, p.content FROM pages p LEFT JOIN pages_done pd ON p.type = pd.type AND p.id = pd.id WHERE pd.type IS NOT NULL LIMIT 4999,1}}
      3879.22 µs/# 258 # 257.78 #/sec 1000.839 net-ms
      % timerate {db eval {SELECT type, id, content FROM pages WHERE (type, id) IN (SELECT type, id FROM pages_done) LIMIT 4999,1}}
      2326.35 µs/# 431 # 429.86 #/sec 1002.658 net-ms


      1. bee4
        29.07.2022 13:40
        +2

        Ну, тем не менее, уже близко к уровню ответов на stackoverflow. :v)


        1. sebres
          29.07.2022 14:19

          Ну как близко... https://stackoverflow.com/a/2246793

          Проблема с теми AI здесь всегда собственно обучающая выборка - на чем его натаскивают и насколько это "правильно" им разбирается, от "синтаксиса" и "морфологии" до абстракций каких-нибудь... А сарказм если, а пограничные условия какие-либо, и т.п.


  1. maximinit
    28.07.2022 17:29

    Спасибо. Реально полезная статья.


  1. AlexSky
    29.07.2022 16:34
    +3

    Скормил GPT-3 рандомный кусок кода из ядра Linux.
    Болдом - ответ GPT-3, остальное мой ввод.

    Explain this code:

    static bool nft_payload_fast_eval(const struct nft_expr *expr,
              struct nft_regs *regs,
              const struct nft_pktinfo *pkt)
    {
      const struct nft_payload *priv = nft_expr_priv(expr);
      const struct sk_buff *skb = pkt->skb;
      u32 *dest = &regs->data[priv->dreg];
      unsigned char *ptr;
    
      if (priv->base == NFT_PAYLOAD_NETWORK_HEADER)
        ptr = skb_network_header(skb);
      else {
        if (!pkt->tprot_set)
          return false;
        ptr = skb_network_header(skb) + pkt->xt.thoff;
      }
    
      ptr += priv->offset;
    
      if (unlikely(ptr + priv->len > skb_tail_pointer(skb)))
        return false;
    
      *dest = 0;
      if (priv->len == 2)
        *(u16 *)dest = *(u16 *)ptr;
      else if (priv->len == 4)
        *(u32 *)dest = *(u32 *)ptr;
      else
        *(u8 *)dest = *(u8 *)ptr;
      return true;
    }

    This code is from the Linux kernel and it is used to evaluate the payload of a packet. The code is written in C.

    Explain in detail

    The code is used to evaluate the payload of a packet. The payload is the data part of the packet, which contains the actual information being sent. The code is written in C.

    The code first checks if the base is set to NFT_PAYLOAD_NETWORK_HEADER. If it is, the code sets the pointer (ptr) to the beginning of the network header. If not, the code checks if the tprot_set flag is set. If it is, the code sets the pointer to the beginning of the transport header. Otherwise, the code returns false.

    Next, the code adds the offset to the pointer. The offset is the number of bytes from the beginning of the header that the payload starts at.

    Then, the code checks if the payload is too long for the packet. If it is, the code returns false.

    Это очень круто!

    Оно даже распознало, что в pkt->xt.thoff лежит смещение до транспортного заголовка!


  1. sozercanie_kosmosa
    30.07.2022 06:40

    Вангую новый плагин для IDA