LANG·I Язык Глава 7 из 65

Собеседник из строк

Строки — это текст, который программа режет на куски и склеивает заново. Этого хватит, чтобы шаг за шагом собрать свою ELIZA, бота 1966 года, которому люди доверяли личное. А в конце главы разберём, как программа печатает саму себя.

С нуля 55 минут Python Программирование ИИ История

Опирается на: 06 · Списки

Что вы унесёте из главы

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

1Может ли программа напечатать саму себя?

Дежурство из прошлой главы оставило вопрос: в какой стране чаще всего трясло? Ответ лежит в седьмом поле каталога, а там вместо числа текст: «125 km SE of Petropavlovsk-Kamchatsky, Russia». Страна — кусок после последней запятой. Чтобы его вырезать, нужно уметь обращаться с текстом так же уверенно, как со списком чисел.

Страну мы вырежем в первом же разделе, а потом соберём из строк собеседника. В 1966 году программа, у которой не было ничего, кроме набора шаблонов, разговаривала с людьми так, что они рассказывали ей о своих семьях и страхах. Мы напишем свою — по-русски, за пять шагов, — и к концу главы с ней можно будет поговорить. Напоследок ответим на первый большой вопрос курса: как программа печатает сама себя.

Страна после запятой

Строка — это последовательность символов, и с ней работает всё, что вы знаете о списках: длина, индексы с нуля, отрицательные индексы, срезы, проверка in, цикл по элементам. Элементы строки — отдельные символы, тоже строки длины один.

Для строк in умеет больше, чем для списков: он находит любую подстроку — кусок, идущий подряд. А split разрезает строку по разделителю и возвращает список кусков. Последний кусок — страна. Можно было бы найти позицию последней запятой и взять срез после неё, но здесь прячется ловушка.

Метод rfind ищет подстроку с конца и возвращает её позицию, а если не нашёл — число −1. Для хребта Карлсберг в Индийском океане запятой нет, comma равно −1, и срез place[1:] молча откусывает первую букву. Ошибки нет, ответ неверный. А split без запятой вернёт список из одной строки, и последним куском окажется всё название. Запомните: −1 из find и rfind — это не «ничего», а вполне рабочий индекс последнего символа.

Теперь можно посчитать толчки по странам. Словарей у нас пока нет. В прошлой главе счётчики магнитуд жили в одном списке, а номером служила сама магнитуда; страну в номер не превратишь, поэтому заведём два параллельных списка: названия и числа.

Индонезия впереди с большим отрывом: 1883 толчка за одиннадцать лет. Италия, где землетрясения то и дело попадают в новости, получила 19 — и ещё один толчок наш способ не заметил: он записан как «central Italy», без запятой. В живых данных такой разнобой встречается на каждом шагу: в нашем каталоге без запятой записана больше чем каждая четвёртая строка, 5231 из 19 073, — чаще всего толчки в океане, где страны нет вовсе.

Таблицу ровной делает последняя строка программы. После двоеточия в фигурных скобках f-строки можно указать не только число знаков после запятой, но и ширину поля: :<10 — десять символов с выравниванием влево, :>5 — пять вправо. А '#' * 31 повторяет строку, как [0] * 36 повторял список, — отсюда столбики.

Метод index ищет позицию элемента в списке и каждый раз проходит его от начала. Пока стран восемь, это незаметно. Если бы мы захотели посчитать все 337 разных кусков после последней запятой — страны, штаты, районы океана, — двух параллельных списков стало бы мало — к этому мы вернёмся в конце главы.

Кембридж, 1966. Доктор из шаблонов

ЧеловекELIZA
Men are all alike.
Все мужчины одинаковые.
IN WHAT WAY
В каком смысле?
They're always bugging us about something or other.
Они всегда к нам с чем-нибудь пристают.
CAN YOU THINK OF A SPECIFIC EXAMPLE
Можете вспомнить конкретный случай?
Well, my boyfriend made me come here.
Ну, мой парень заставил меня прийти сюда.
YOUR BOYFRIEND MADE YOU COME HERE
Ваш парень заставил вас прийти сюда?
He says I'm depressed much of the time.
Он говорит, что я почти всё время подавлена.
I AM SORRY TO HEAR YOU ARE DEPRESSED
Мне жаль слышать, что вы подавлены.

В третьем ответе никакого понимания нет: программа нашла слово «my», взяла остаток фразы и поменяла местоимения — «my» на «your», «me» на «you». Всё остальное делает человек, который сам достраивает смысл. Эти четыре ответа мы и воспроизведём, только по-русски. Нам понадобится пять умений: приводить текст к одному виду, искать в нём ключевые слова, менять «я» на «вы», собирать ответ по шаблону и вести разговор.

Шаг 1. Строку не изменить

Люди пишут «Мне кажется», «мне кажется» и «МНЕ КАЖЕТСЯ!!!», а программа должна понять всё одинаково. Первое, что делает ELIZA, — приводит фразу к нижнему регистру и отрезает знаки препинания по краям. Для этого у строк есть методы lower и strip. Следите, что после них станет с исходной строкой.

lower не изменил s: он построил новую строку и вернул её. Так устроены все методы строк — lower, upper, strip, replace. А попытка заменить одну букву кончается ошибкой: строки, как числа и кортежи, неизменяемы. Чтобы «поменять» строку, строят новую и перевешивают на неё имя: s = s.lower(). После списков, которые в прошлой главе менялись на месте и устраивали нам неприятности, это скорее облегчение: строку, на которую смотрят два имени, никто не испортит.

Строковый микроскоп. Над каждой буквой — её индекс. Нажимайте методы: результат — новая строка, изменившиеся символы подсвечены, а исходная s остаётся прежней. Строку и аргументы a, b можно менять.

Попробуйте в микроскопе find с несуществующим словом — получите −1 — и strip на строке, где восклицательный знак стоит в середине: strip срезает только края. Отдельно присмотритесь к split() без аргумента: он режет по любым пробелам, сколько бы их ни было подряд, и выбрасывает пустые куски. Это самый частый способ разбить фразу на слова.

Шаг 2. Ключевые слова

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

Ключи здесь — начала слов: «одинаков» подходит к «одинаковые» и «одинаковы», «мам» — к «мама», «маме», «маму». В русском языке слово меняет окончание, и искать по началу — простейший способ это обойти. Но последний ответ выдаёт подвох: «навсегда» содержит «всегда», и ELIZA просит вспомнить конкретный случай там, где его нет.

Лекарство — искать ключ в начале слова. Перед каждым словом фразы стоит пробел, кроме первого; добавим пробел и перед первым, и будем искать " " + key. Тогда « всегда» найдётся в « они всегда», но не в « навсегда». А метод find вернёт индекс, с которого начинается найденная подстрока: позиция ключа понадобится на четвёртом шаге.

В переменной text лежит " я хочу домой ". Что вернёт text.find(" хочу")?

Позиции: 0 — пробел, 1 — «я», 2 — пробел, 3 — «х». Подстрока " хочу" начинается с пробела на позиции 2, её и вернёт find. Сама буква «х» стоит на позиции 3 — потому дальше в коде появится pos + 1.

Шаг 3. Я и вы

Самое эффектное в ELIZA — третий ответ из разговора 1966 года: «Мой парень заставил меня прийти сюда» — «Ваш парень заставил вас прийти сюда?». Нужно поменять местоимения: «я» на «вы», «мой» на «ваш», «меня» на «вас» и обратно. Первым приходит в голову метод replace, который заменяет одну подстроку другой. Попробуем.

Две ловушки сразу. Первая — replace не знает, где кончаются слова: «я» сидит внутри «моя», «меня» и «яблоко», и получаются «мовы», «менвы» и «выблоко». Вторая — замены идут по очереди: сначала «меня» стало «вас», а следующая строка превратила это «вас» обратно в «меня». Замена отменила сама себя — как неудачный обмен чая на кофе в главе 2.

Обе ловушки обходит один приём: разрезать фразу на слова методом split, для каждого слова один раз решить по таблице замен, на что его заменить, и собрать слова обратно. Собирать умеет метод join: он вызывается у строки-разделителя и склеивает список строк, вставляя разделитель между ними.

Запись " ".join(words) поначалу кажется вывернутой наизнанку: склеиваем список, а метод вызываем у пробела. Логика такая: склеить можно только строки, поэтому и метод принадлежит строке — той, что встанет между кусками, будь то пробел, дефис или пустая строка. split и join обратны друг другу. Теперь ваш ход.

Напишите функцию reflect(phrase), которая меняет местоимения по таблице PAIRS: каждое слово из левого столбца заменяется словом из правого, и наоборот. Остальные слова не трогаются. Фраза — слова в нижнем регистре через пробел, без знаков препинания. Например, reflect("вы знаете моего брата") → "я знаю вашего брата".

Заготовка попадает в обе ловушки. Запустите её на "моя мама любит яблоки" и на "вы мне не верите".

Разрежьте фразу на слова. Для каждого слова пройдите по PAIRS: совпало с левым — берите правое, совпало с правым — левое, и сразу выходите из цикла по парам (break), чтобы не заменить слово дважды. Готовые слова собирайте в список и склейте " ".join(...).

Сравнение word == mine требует совпадения всего слова, и подстроки внутри слов больше не задеваются. А break гарантирует, что каждое слово заменяется не больше одного раза, — обмен не отменяет сам себя. Глаголы в таблице нужны потому, что по-русски «я верю», но «вы верите», а по-английски глагол остался бы прежним: I believe, you believe.

Шаг 4. Шаблоны

Ответ «Ваш парень заставил вас прийти сюда?» собран из двух частей: слова «Ваш», которое знает правило, и остатка фразы после ключа «мой», который пришёл от человека и прошёл через зеркало. Остаток вырезает срез: ключ найден на позиции pos, значит, остаток начинается после него. Шаблон ответа — строка с дыркой {}, а заполняет её метод format.

Срез text[pos + 1 + len(key):] читается так. На позиции pos стоит пробел перед ключом, на pos + 1 — первая буква ключа, а через len(key) букв ключ кончается. Всё, что дальше, — остаток; strip(" ,") отрезает от него пробелы и запятую. Вызов repr(rest) показал остаток в кавычках, чтобы было видно, где он начинается и кончается. К этой функции мы вернёмся в конце главы: на ней держится программа, которая печатает сама себя.

format — старший брат f-строк из главы 2. Разница в том, когда заполняются дырки. f-строка вычисляется сразу, в той строке программы, где написана, из переменных, которые есть в этот момент. Строку для format можно написать заранее, положить в список правил и заполнить потом, когда придёт фраза. Правилам ELIZA нужно именно это. Внутри фигурных скобок у format работают те же указания, что у f-строк: "{:>8}".format(3.5) выровняет число вправо в восьми позициях.

Осталась одна деталь из оригинала. У каждого правила ELIZA было несколько шаблонов, и программа брала их по очереди, чтобы не повторяться. Мы сделаем так же: для каждого правила будем помнить, сколько раз оно срабатывало, и брать шаблон с номером turn % len(replies) — по кругу. Впишите фразу в виджет ниже и проследите, как ELIZA её разбирает.

Каждый ответ — пять шагов: чистка, поиск ключа, остаток, зеркало, шаблон. Правила спрятаны под спойлером: меняйте ключи и шаблоны, добавляйте свои — и кнопкой «Перенести в программу» отправьте их в программу ниже. Правила запоминаются в этом браузере.

Попробуйте сломать ELIZA — это несложно. «Я хочу новую работу» превращается в «Что изменится, если вы получите новую работу?»: шаблон подобран так, чтобы остаток стоял в том же падеже, что во фразе человека, — «хочу работу», «получите работу». А «Я хочу, чтобы меня любили» с тем же шаблоном даёт «Что изменится, если вы получите чтобы вас любили?»: шаблон рассчитан на предмет, а получил целое придаточное. Английской ELIZA было проще — английские слова почти не меняют окончаний. Русской приходится подбирать шаблоны под то, как глагол во фразе управляет словами после себя, и всё равно она будет ошибаться. Правила можно уточнять бесконечно, но понимания в них от этого не прибавится.

Шаг 5. Разговор

Осталось собрать всё вместе и добавить цикл разговора: input читает реплику, answer отвечает, слово «пока» прекращает беседу. Правила из таблицы выше попадают в блок RULES.

Шестьдесят с небольшим строк — и никакого знания о мире. Программу можно запустить прямо в ячейке — сервер будет спрашивать вас в поле под кодом, — но удобнее разговаривать в окне ниже. Оно берёт программу из ячейки со всеми вашими правками.

Ваша ELIZA, запущенная на сервере. Для начала повторите разговор 1966 года — четыре фразы из таблицы выше, по-русски. Потом поговорите о своём.

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

Склонность приписывать программе понимание, которого в ней нет, так и называют — эффектом ELIZA. Он не исчез с тех пор, как программы стали умнее. Современные языковые модели устроены совсем иначе: у них нет таблицы правил, они учатся продолжать текст на огромном количестве примеров. Как машина учится тому, чему её не учили, — в главе 63, а маленькую языковую модель с нуля можно собрать в курсе «Росток» на этом сайте. А вопрос Вейценбаума остался: что стоит за словами машины?

Мастерская: чистка текста

ELIZA держится на трёх умениях: привести текст к одному виду, разрезать на слова и собрать обратно. Они нужны везде, где программа читает написанное людьми, — от поисковой строки до разбора отзывов. В мастерской три задачи, и первая — про перевёртыши.

Палиндром — фраза, которая одинаково читается в обе стороны: «А роза упала на лапу Азора». Строчку обычно приписывают Афанасию Фету; Мальвина диктует её Буратино в «Золотом ключике» Алексея Толстого. Читается она одинаково, только если не обращать внимания на пробелы и заглавные буквы. Значит, сначала нужно оставить одни буквы.

Срез с шагом −1 разворачивает строку. Метод isalpha отвечает, буква ли перед ним; есть ещё isdigit — цифра ли — и isalnum — буква или цифра. Строку letters мы собираем по символу: letters += ch каждый раз строит новую строку, ведь старую изменить нельзя. На тысяче символов это незаметно. На миллионе удобнее собрать символы в список и в конце склеить "".join(...).

Строки и списки — родня: обе последовательности, и len, индексы, срезы, in и циклы работают у них одинаково. Но список можно менять, а строку нельзя, и в списке лежит что угодно, а в строке — только символы. Даже in ведёт себя по-разному: у списка ищет элемент, у строки — подстроку. Превратить одно в другое легко: list("кот") даёт ['к', 'о', 'т'], а "".join собирает обратно.

Напишите функцию is_palindrome(text): True, если текст читается одинаково в обе стороны, когда из него выброшено всё, кроме букв и цифр, и регистр не важен. Пустая строка и строка без букв — палиндромы.

Сначала очистите текст: пройдите по символам text.lower() и оставьте те, у которых isalnum() даёт True.

Тесты проверяют и текст в миллион символов. Собирать строку через += в Python обычно достаточно быстро, но надёжнее сложить символы в список и склеить "".join(chars).

Можно обойтись и без разворота: сравнивать clean[i] с clean[-1 - i] для первой половины индексов. Это экономит память на перевёрнутую копию — пригодится, когда тексты станут очень большими.

Вторая задача — из любой анкеты. Люди пишут имена как попало: заглавными, с лишними пробелами, с маленькой буквы. Метод capitalize делает первую букву строки заглавной, остальные — строчными, а title поднимает первую букву каждого слова.

Напишите функцию short_name(full), которая превращает полное имя в формат «Фамилия И. О.»: short_name(" толстой ЛЕВ николаевич ") → "Толстой Л. Н.". Первое слово — фамилия, следующие — имя и отчество; их может не быть. Двойная фамилия пишется через дефис, и заглавными в ней должны стать обе части: «Салтыков-Щедрин». Если строка пустая или из одних пробелов, верните пустую строку.

Заготовка падает на коротких именах, не поднимает регистр инициалов и портит двойные фамилии: "салтыков-щедрин".capitalize() — это "Салтыков-щедрин".

Фамилию разрежьте по дефису, каждую часть сделайте capitalize() и склейте "-".join(...). Инициалы — первые буквы остальных слов, переведённые в верхний регистр, с точкой.

Метод title справился бы с фамилией за один вызов: "салтыков-щедрин".title() даёт «Салтыков-Щедрин», он поднимает букву после любого не-буквенного символа. Но у него свои сюрпризы: "2-й отряд".title() превращается в «2-Й Отряд». Поэтому в решении буквы поднимаем мы сами: так видно, какую и почему.

Третья задача — подсчёт слов. Казалось бы, у строк есть метод count, который считает вхождения подстроки. Но он находит «война» и внутри «войнами», а «Война» с заглавной пропускает.

Напишите функцию count_word(text, word), которая считает, сколько раз слово word встречается в тексте как отдельное слово, без учёта регистра. Слова в тексте разделены пробельными символами; знаки препинания из строки PUNCT, прилипшие к слову спереди или сзади, не считаются его частью. Функция должна справиться со всем романом «Война и мир» — он лежит в песочнице в файле /data/texts/war_and_peace.txt.

text.lower().split() даёт слова. С каждого срежьте знаки препинания: w.strip(PUNCT) — strip умеет отрезать любые символы из строки-набора.

Не забудьте привести к нижнему регистру и само word.

В «Войне и мире» слово «война» в этой форме встречается 52 раза, «мир» — 46, а «Пьер» — 1390. Формы слова — «войны», «войну», «войной» — функция не считает: для этого нужна лемматизация, приведение слова к начальной форме, и это уже задача компьютерной лингвистики.

Программа, которая печатает себя

В главе 0 вы запускали программу из двух строк, которая печатает собственный текст, символ в символ:

Тогда мы обещали объяснить, как она устроена. Теперь у нас есть всё нужное — кроме двух деталей: функции repr и старого способа заполнять шаблоны знаком %.

Строка и её запись

У каждой строки есть два вида. Первый — то, что она есть: символы, которые печатает print. Второй — то, как её записывают в коде Python: в кавычках, с особыми символами вроде перевода строки, записанными через обратную косую черту. Второй вид даёт функция repr — от representation, «представление».

print(s) показал две строки текста: \n внутри строки — это один символ, перевод строки. А repr(s) вернул строку, которую можно вставить в программу: с кавычками по краям и с \n из двух символов — обратной косой и буквы n. Кавычки Python выбрал одинарные, потому что внутри есть двойные. Поэтому и длины разные: 36 символов у строки и 39 у её записи. Представление строки — это её исходный код.

Второй инструмент — оператор % для строк, самый старый способ заполнять шаблоны в Python; он пришёл из функции printf языка C. В строке-шаблоне стоят дырки: %s — подставить значение как текст, %r — подставить его repr. А чтобы в результате получился сам знак процента, его пишут дважды: %%.

Разбор по частям

Теперь читаем куайн. Первая строка программы кладёт в s строку из 20 символов: s = %r, перевод строки и print(s %% s). Это шаблон всей программы с одной дыркой на месте строкового литерала. Вторая строка заполняет шаблон самим шаблоном: s % s. Дальше работают два правила оператора %.

  • Дырка %r заменяется на repr(s), то есть на строку s в том виде, в каком она записана в первой строке программы: в одинарных кавычках, с \n из двух символов и с двумя процентами. Первая строка вывода превращается в s = 's = %r\nprint(s %% s)' — копию первой строки программы.
  • %% вне дырки превращается в один %. Настоящий перевод строки печатается как перенос, и вторая строка вывода — print(s % s), копия второй строки программы.

Получается, что программа не содержит себя целиком — это невозможно, она была бы длиннее себя. Она содержит шаблон себя, в котором одно место оставлено пустым, и правило, как это место заполнить. Заполняют его тем же шаблоном, но в форме записи — через repr. Шаблон описывает программу, а repr шаблона — тот кусок программы, где шаблон записан.

Анатомия куайна. Шаги 1–4 проводят шаблон через repr и подстановку к сверке с исходником. Вкладки сверху — тот же приём, записанный через format и через f-строку. Галочки внизу ломают куайн на вкладке «%»: проверьте, что выйдет.

Да, может — и без чтения своего файла. Программа хранит шаблон своего текста с одной дыркой и заполняет дырку записью самого шаблона: функция repr превращает строку в тот вид, каким она стоит в коде, а подстановка вставляет её на место. Так шаблон описывает программу, а запись шаблона — то место программы, где этот шаблон лежит, и круг замыкается. Приём не зависит от языка: куайны пишут на C, на Haskell, на ассемблере, где угодно, где есть строки и способ подставить строку в строку. Дальше этот вопрос ведёт в главу 56: способность программы получить собственный текст — ключ к доказательству, что некоторые задачи о программах не решает никакой компьютер.

Тот же куайн можно записать и современными средствами. Вариант с format — почти дословный перевод: дырка {!r} значит «подставь repr аргумента».

С f-строкой приём выглядит иначе. f-строка вычисляется сразу, отложить её как шаблон нельзя, поэтому строка s здесь хранит всю вторую строку программы, а сама вторая строка печатает «s = », потом repr(s), потом перевод строки и саму s. В первой строке \\n записан с двойной косой чертой: внутри строки он должен остаться двумя символами, обратной косой и буквой n.

Проверьте, что вы поняли приём, — положите в куайн свой багаж.

Напишите куайн, первая строка которого — комментарий с вашей подписью, например # куайн Ады. Вывод программы должен совпадать с её текстом символ в символ, включая комментарий. Читать собственный файл и пользоваться модулями inspect, sys и модулем курса cs нельзя.

Запустите заготовку: она печатает всё, кроме первой строки. Комментарий Python пропускает, поэтому печатать его должна сама программа — значит, он должен быть в шаблоне.

Шаблон описывает всю программу, сверху вниз. Если программа начинается с комментария и перевода строки, с них же должен начинаться и шаблон. Не забудьте, что после этого изменится и запись шаблона в строке s = … — но об этом позаботится %r.

Осторожно с кавычками: если в подписи есть апостроф, repr выберет двойные кавычки, и запись строки в программе должна быть такой же.

Шаблон теперь начинается с комментария и \n — так же, как программа. Подстановка %r сама вписывает в строку s = … новую, более длинную запись шаблона. Так и работает «багаж» Томпсона: перед строкой s = … можно поставить сколько угодно строк, лишь бы шаблон начинался с них же.

Куда дальше

Чтобы узнать, сколько раз в «Войне и мире» встречается слово «война», хватит одной строки: words.count("война") — и через долю секунды ответ, 52. А какие слова в романе самые частые? Для этого нужно посчитать каждое слово. С тем, что у нас есть, остаётся тот же приём, что со странами: список различных слов и параллельный список счётчиков. Попробуем на первых тысячах слов романа.

Каждое удвоение текста — примерно втрое дольше. Виноваты in и index: чтобы узнать, встречалось ли слово, Python проходит весь список уже известных слов, а он растёт вместе с текстом. На всём романе, почти 450 тысячах слов и 52 тысячах различных словоформ, счёт занял бы больше полуминуты, а песочница остановит его через десять секунд. На библиотеке из сотни романов — часы. Нужна структура, которая по слову сразу находит его счётчик, не перебирая остальные. Это словарь, и с ним весь роман считается быстрее, чем вы успеете моргнуть. В главе 8 мы посчитаем с его помощью буквы, как это делали изобретатели телеграфа, и узнаем, почему буква E в азбуке Морзе — одна точка.