LANG·I Язык Глава 7 из 65
Собеседник из строк
Строки — это текст, который программа режет на куски и склеивает заново. Этого хватит, чтобы шаг за шагом собрать свою ELIZA, бота 1966 года, которому люди доверяли личное. А в конце главы разберём, как программа печатает саму себя.
Язык
Опирается на: 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. Он не исчез с тех пор, как программы стали умнее. Современные языковые модели устроены совсем иначе: у них нет таблицы правил, они учатся продолжать текст на огромном количестве примеров. Как машина учится тому, чему её не учили, — в главе 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 шаблона — тот кусок программы, где шаблон записан.
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 в азбуке Морзе — одна точка.