LANG·I Language Chapter 7 of 65

A conversation made of strings

A string is text that a program cuts into pieces and glues back together. That is enough to build, step by step, your own ELIZA, the 1966 program people confided in. At the end of the chapter we take apart a program that prints itself.

From zero 55 minutes Python Programming AI History

Builds on: 06 · Lists

What you will take away

  • cut, search, replace and assemble text with string methods
  • write a rule-based chatbot and talk to it
  • explain how a program that prints its own text works

1Can a program print itself?

The night shift in the last chapter left a question open: which country shook most often? The answer sits in the seventh field of the catalog, and it is text, not a number: “125 km SE of Petropavlovsk-Kamchatsky, Russia”. The country is the piece after the last comma. To cut it out, you need to handle text as confidently as a list of numbers.

We’ll cut out the country in the first section and then build a conversation partner out of strings. In 1966 a program that had nothing but a set of templates talked with people so well that they told it about their families and their fears. We’ll write our own in five steps, and it will give the same four replies the original gave in 1966. By the end of the chapter you’ll be able to talk to it. Last of all we’ll answer the course’s first big question: how does a program print itself?

The country after the comma

A string is a sequence of characters, and everything you know about lists works on it: length, indices from zero, negative indices, slices, the in test, a loop over the items. The items of a string are single characters, which are themselves strings of length one.

For strings, in can do more than for lists: it finds any substring, a piece that runs without gaps. And split cuts a string at a separator and returns a list of the pieces. The last piece is the country. We could instead find the position of the last comma and take a slice after it, but that road hides a trap.

The method rfind searches for a substring from the end and returns its position, or −1 if it finds nothing. The Carlsberg Ridge in the Indian Ocean has no comma, comma is −1, and the slice place[1:] quietly bites off the first letter. No error, wrong answer. Without a comma, split returns a list of one string, and the last piece is the whole name. Remember: the −1 from find and rfind doesn’t mean “nothing.” It is a perfectly good index, the index of the last character.

Now we can count quakes by country. We have no dictionaries yet. In the last chapter the magnitude counters lived in one list, and the magnitude itself served as the index; a country can’t be turned into an index, so we’ll keep two parallel lists, one of names and one of numbers.

Indonesia leads by a long way: 1,883 quakes in eleven years. Italy, where earthquakes keep making the news, got 19, and our method missed one more, recorded as “central Italy”, with no comma. Data in the wild is full of inconsistencies like this: in our catalog more than one row in four, 5,231 of 19,073, has no comma at all, mostly quakes out at sea, where there is no country to name.

The last line of the program keeps the table straight. After the colon inside the braces of an f-string you can give the width of the field as well as the number of decimal places: :<10 means ten characters aligned left, :>5 five aligned right. And '#' * 31 repeats a string the way [0] * 36 repeated a list, which gives the bars.

The method index looks for an item’s position in a list, walking it from the start every time. With eight countries that costs nothing. If we wanted to count all 337 different pieces after the last comma (countries, states, stretches of ocean), two parallel lists would no longer do. We’ll come back to that at the end of the chapter.

Cambridge, 1966: a doctor made of templates

PersonELIZA
Men are all alike.
keyword: alike
IN WHAT WAY
In what way?
They’re always bugging us about something or other.
keyword: always
CAN YOU THINK OF A SPECIFIC EXAMPLE
Can you think of a specific example?
Well, my boyfriend made me come here.
keyword: my
YOUR BOYFRIEND MADE YOU COME HERE
Your boyfriend made you come here?
He says I’m depressed much of the time.
keyword: depressed
I AM SORRY TO HEAR YOU ARE DEPRESSED
I am sorry to hear you are depressed.

The replies have no question marks. The user wasn’t allowed to type one either: on MIT’s time-sharing system a question mark deleted the line. Under each line of the table is the word our own ELIZA will catch and the reply it will give. There is no understanding in the third reply: the program found the word “my,” took the rest of the sentence and swapped the pronouns, “my” for “your,” “me” for “you.” The person does everything else, filling in the meaning. We need five skills to reproduce these four replies: bring text to one form, find keywords in it, turn “I” into “you,” build a reply from a template and keep a conversation going.

Step 1. A string can’t be changed

People type “I feel,” “i feel” or “I FEEL!!!” and expect the program to understand them all the same way. The first thing ELIZA does is bring the sentence to lower case and cut the punctuation off its ends. Strings have the methods lower and strip for that. Watch what becomes of the original string.

lower didn’t change s: it built a new string and returned it. All string methods work this way: lower, upper, strip, replace. An attempt to replace a single letter ends in an error: strings, like numbers and tuples, are immutable. To “change” a string, you build a new one and move the name onto it: s = s.lower(). After the lists of the last chapter, which changed in place and got us into trouble, this comes as a relief. Nobody can spoil a string that two names point to.

The string microscope. Above each letter is its index. Press the methods: the result is a new string with the changed characters highlighted, and the original s stays as it was. You can edit the string and the arguments a and b.

Try find in the microscope as it stands: “me” turns up at position 2, inside “Sometimes.” A word that isn’t there gives −1. Try strip on a string with the exclamation mark in the middle: strip trims only the ends. And look closely at split() without an argument: it cuts at any run of spaces, however long, and throws away the empty pieces. It is the usual way to break a sentence into words.

Step 2. Keywords

Next, ELIZA has to spot a word in the sentence that it has a reply for. The rules are handy to keep as a list of “key, reply” tuples, like the catalog records of the last chapter. Order matters: the first rule that fits wins, so the more specific rules go higher up.

A key only has to match the beginning of a word: “mother” also fits “mothers” and “mother’s.” Words change their endings, and searching for the start of a word is the simplest way around that. The last reply shows the catch, though: “smother” contains “mother,” and ELIZA asks about the family of someone who was complaining about their friends.

The cure is to look for the key at the start of a word. Every word in the sentence has a space before it except the first, so we put a space before the first one too and search for " " + key. Then “ mother” is found in “ my mother phones”, but not in “ my friends smother”. The method find returns the index where the substring it found begins: we’ll need the key’s position in step 4.

The variable text holds " i want to go home ". What does text.find(" want") return?

Positions: 0 is a space, 1 is “i”, 2 a space, 3 “w”. The substring " want" starts with the space at position 2, and that is what find returns. The letter “w” itself is at position 3, which is why pos + 1 will turn up in the code later.

Step 3. I and you

The most striking thing about ELIZA is the third reply of the 1966 conversation, where “Well, my boyfriend made me come here” comes back as “Your boyfriend made you come here.” The pronouns have to be swapped: “I” for “you,” “my” for “your,” “me” for “you,” and back again. The first tool that comes to mind is the method replace, which replaces one substring with another.

Two traps at once. First, replace doesn’t know where words end: “i” sits inside “think,” “it,” “is,” “time” and “visit,” and out come “thyounk”, “yout” and “vyousyout”. Second, the replacements run one after another: “me” became “you,” and the next line turned that “you,” along with the one that was there from the start, into “me.” Both pronouns came out as “me,” like the botched swap of tea and coffee in Chapter 2.

One technique gets around both traps: cut the sentence into words with split, decide once for each word what to replace it with, using a table of swaps, and put the words back together. Gluing them back together is the job of the method join: you call it on the separator string, and it glues a list of strings, inserting the separator between them.

The notation " ".join(words) looks inside out at first: we’re gluing a list, yet we call the method on a space. The logic goes like this. Only strings can be glued, so the method belongs to a string, the one that goes between the pieces, be it a space, a hyphen or the empty string. split and join undo each other. Now it’s your turn.

Write a function reflect(phrase) that swaps pronouns according to the table PAIRS: each word from the left column is replaced by the word from the right one, and the other way round. The word “you” appears in two pairs; the first pair wins, so it turns into “i”. Other words stay as they are. The phrase is made of lower-case words separated by spaces, with no punctuation. For example, reflect("you know my brother") → "i know your brother".

The starter falls into both traps. Run it on "it is my mistake" and on "you don't believe me".

Cut the phrase into words. For each word, go through PAIRS: if it matches the left word, take the right one; if it matches the right word, take the left one; and leave the loop over the pairs at once (break), so that the word isn’t replaced twice. Collect the finished words in a list and glue them with " ".join(...).

The comparison word == mine needs the whole word to match, so letters inside other words are left alone. And break makes sure each word is replaced at most once: the swap can’t undo itself. The table needs hardly any verbs, since an English verb barely changes with the person: I believe, you believe. The exception is “am”: I am, but you are. One English word can’t always be mirrored right. “You” stands for both “I” and “me,” and a table can turn it into only one of them.

Step 4. Templates

The reply “Your boyfriend made you come here” is built from two parts: the word “Your,” which the rule knows, and the rest of the sentence after the key “my,” which came from the person and went through the mirror. A slice cuts out the rest: if the key was found at position pos, the rest begins after it. The reply template is a string with a hole, {}, and the method format fills it in.

Here is how to read the slice text[pos + 1 + len(key):]. Position pos holds the space before the key, pos + 1 the key’s first letter, and len(key) letters later the key ends. Everything after that is the rest; strip(" ,") trims the spaces off it, and a comma too, should the person put one after the key. Calling repr(rest) showed the rest in quotes, so you can see where it begins and ends. We’ll come back to this function at the end of the chapter: a program that prints itself rests on it.

format is the older brother of the f-strings from Chapter 2. The difference is when the holes get filled. An f-string is evaluated at once, on the line of the program where it is written, from the variables that exist at that moment. A string for format can be written in advance, put into the list of rules and filled in later, when a sentence arrives. That is what ELIZA’s rules need. Inside the braces format understands the same instructions as f-strings: "{:>8}".format(3.5) aligns the number to the right in eight positions.

One detail of the original remains. Each ELIZA rule had several templates, and the program used them in turn so as not to repeat itself. We’ll do the same: for every rule we’ll remember how many times it has fired and take template number turn % len(replies), round and round. Nearly all our templates come straight from the DOCTOR script printed at the end of Weizenbaum’s paper. Type a sentence into the widget below and follow how ELIZA takes it apart.

Every reply takes five steps: clean up, find a key, take the rest, mirror it, fill the template. The rules are folded away below: change keys and templates, add your own, and the “Send to the program” button puts them into the program further down. Your rules are remembered in this browser.

Try to break ELIZA; it isn’t hard. “I want a new job” turns into a reply straight out of the 1966 script: “What would it mean to you if you got a new job?” But “I want you to listen to me,” given the same template, comes back as “What would it mean to you if you got i to listen to you?” In English one word, “you,” does the work of both “I” and “me,” and our table always makes it “i”. And the pair of “am” and “are,” which mends “I am tired,” wrecks “My parents are angry”: ELIZA replies “Your parents am angry?” Weizenbaum’s script turned “am” into “are” but never the other way round, and patched the damage with further rules. You can keep refining the rules forever, and none of it adds any understanding.

Step 5. The conversation

All that remains is to put everything together and add the conversation loop: input reads a line, answer replies, and the word “bye” ends the talk. The rules from the table above go into the RULES block.

Sixty lines, and not a shred of knowledge about the world. You can run the program right in the cell, and the server will ask for your lines in a box under the code, but it is easier to talk in the window below. It takes the program from the cell, with all your edits.

Your ELIZA, running on the server. Start by replaying the conversation of 1966, the four lines from the table above. Then talk about something of your own.

After a few lines you can see how it is done: the replies repeat, and a sentence without keywords gets a stock “Please go on.” And yet for the first few minutes the conversation draws you in. That frightened Weizenbaum.

The tendency to credit a program with understanding it doesn’t have is called the ELIZA effect. It hasn’t gone away as programs grew smarter. Today’s language models are built quite differently: they have no table of rules and learn to continue text from an enormous number of examples. How a machine learns what nobody taught it is the subject of Chapter 63, and you can build a small language model from scratch in the Sprout course on this site. Weizenbaum’s question remains: what stands behind a machine’s words?

Workshop: cleaning up text

ELIZA rests on three skills: bringing text to one form, cutting it into words and gluing it back together. You need them wherever a program reads what people wrote, from a search box to sorting through product reviews. The workshop has three tasks, and the first is about palindromes.

A palindrome is a phrase that reads the same in both directions: “A man, a plan, a canal: Panama!” It was made up by the British wordplay enthusiast Leigh Mercer and appeared in the journal Notes and Queries in 1948. It reads the same only if you ignore the spaces, the punctuation and the capitals. So the first job is to keep nothing but the letters.

A slice with step −1 reverses a string. The method isalpha tells you whether a character is a letter; there are also isdigit, for a digit, and isalnum, for a letter or a digit. We build the string letters one character at a time, and letters += ch builds a new string every time, since the old one can’t be changed. On a thousand characters the cost is invisible. On a million it’s better to collect the characters in a list and glue them together at the end with "".join(...).

Strings and lists are related: both are sequences, and len, indices, slices, in and loops work the same way on both. But a list can be changed and a string can’t, and a list holds anything while a string holds only characters. Even in behaves differently: in a list it looks for an item, in a string for a substring. Turning one into the other is easy: list("cat") gives ['c', 'a', 't'], and "".join puts it back.

Write a function is_palindrome(text) that returns True if the text reads the same in both directions once everything except letters and digits has been thrown out and case is ignored. An empty string and a string without letters are palindromes.

Clean the text first: go through the characters of text.lower() and keep those for which isalnum() gives True.

The tests include a text a million characters long. Building a string with += is usually fast enough in Python, but it is safer to put the characters in a list and glue them with "".join(chars).

You can do without the reversed copy: compare clean[i] with clean[-1 - i] for the first half of the indices. That saves the memory a reversed copy takes, which will matter once texts get very large.

The second task comes from any sign-up form. People type their names any old way: in capitals, with extra spaces, all in lower case. The method capitalize makes the first letter of a string a capital and the rest lower case, and title capitalizes the first letter of every word.

Write a function short_name(full) that turns a full name into initials and a surname, the way authors’ names appear on book spines: short_name(" john RONALD reuel TOLKIEN ") → "J. R. R. Tolkien". The last word is the surname; the words before it are first and middle names, and there may be none. A double-barreled surname has a hyphen, and both its parts must get a capital: “Bonham-Carter.” If the string is empty or nothing but spaces, return an empty string.

The starter crashes on short names, doesn’t capitalize the initials and spoils double-barreled surnames: "bonham-carter".capitalize() is "Bonham-carter".

Cut the surname at the hyphen, apply capitalize() to each part and glue them with "-".join(...). The initials are the first letters of the other words in upper case, each followed by a period.

The method title would deal with the surname in one call: "bonham-carter".title() gives “Bonham-Carter”, because it capitalizes the letter after any character that isn’t a letter. But that rule brings surprises of its own: "they're".title() turns into “They'Re”. So in the solution we raise the letters ourselves, and you can see which ones and why.

The third task is counting words. You might think strings already have a method for it, count, which counts occurrences of a substring. But it finds “war” inside “toward” and “reward,” and misses “War” with a capital.

Write a function count_word(text, word) that counts how many times word occurs in the text as a word of its own, ignoring case. Words in the text are separated by whitespace; punctuation from the string PUNCT stuck to the front or back of a word isn’t part of it. The function must cope with the whole of War and Peace: the English translation by Louise and Aylmer Maude is in the sandbox, in the file /data/texts/war_and_peace_en.txt.

text.lower().split() gives you the words. Trim the punctuation off each one with w.strip(PUNCT): strip can cut off any characters from a set given as a string.

Don’t forget to bring word itself to lower case.

In Maude’s War and Peace the word “war” occurs 292 times, “peace” 108 times and “Pierre” 1,784 times. The function doesn’t count other forms of a word, such as “wars” or “war’s”: for that you need lemmatization, bringing every word to its dictionary form, and that is a job for computational linguistics.

A program that prints itself

In Chapter 0 you ran a two-line program that prints its own text, character for character:

Back then we promised to explain how it works. Now we have everything we need except two details: the function repr and the old way of filling templates with the % sign.

A string and how it is written

Every string has two faces. The first is what it is: the characters that print prints. The second is how it is written in Python code: in quotes, with special characters such as a newline spelled out with a backslash. The second face is what the function repr gives you; the name is short for “representation.”

print(s) showed two lines of text: the \n inside the string is a single character, a newline. But repr(s) returned a string you can paste into a program, with quotes at both ends and with \n as two characters, a backslash and the letter n. Python chose single quotes because there are double ones inside. That is why the lengths differ: 32 characters in the string and 35 in its written form. The representation of a string is its source code.

The second tool is the % operator for strings, the oldest way of filling templates in Python; it came from the printf function of the C language. A template string has holes in it: %s means “put the value in as text,” %r means “put in its repr.” To get a percent sign itself into the result, you write it twice: %%.

Taking the quine apart

The first line of the program puts into s a string of 20 characters: s = %r, a newline and print(s %% s). It is a template of the whole program, with a single hole where the string literal goes. The second line fills the template with the template itself: s % s. Two rules of the % operator do the rest.

  • The hole %r is replaced by repr(s), that is, the string s as it is written on the first line of the program: in single quotes, with \n as two characters and with two percent signs. The first line of the output becomes s = 's = %r\nprint(s %% s)', a copy of the first line of the program.
  • %% outside a hole turns into a single %. The newline character itself prints as a line break, and the second line of the output is print(s % s), a copy of the program’s second line.

So the program doesn’t contain itself in full; that is impossible, since it would have to be longer than itself. It contains a template of itself with one place left empty, and a rule for filling that place. The place is filled with the same template in its written form, through repr. The template describes the program, and the repr of the template describes the piece of the program where the template is written down.

The anatomy of a quine. Steps 1–4 take the template through repr and the substitution to a comparison with the source. The tabs at the top show the same technique written with format and with an f-string. The checkboxes at the bottom break the quine on the “%” tab: see what comes out.

Yes, it can, without reading its own file. The program holds a template of its text with one hole, and fills the hole with the written form of the template itself: the function repr turns a string into the form it has in code, and the substitution puts it in place. The template describes the program, the written template describes the place in the program where that template lies, and the circle closes. The method doesn’t depend on the language: quines are written in C, in Haskell, in assembly, anywhere there are strings and a way to put one string inside another. From here the question leads on to Chapter 56: a program’s ability to get hold of its own text is what lets us prove that some questions about programs can’t be answered by any computer.

The same quine can be written with newer tools. The version with format is an almost word-for-word translation: the hole {!r} means “put in the repr of the argument.”

With an f-string the quine looks different. An f-string is evaluated at once and can’t be set aside as a template, so here the string s holds the whole second line of the program, and that line prints “s = ”, then repr(s), then a newline and s itself. On the first line \\n is written with a double backslash: inside the string it has to stay two characters, a backslash and the letter n.

Check that you’ve understood how it works: put some baggage of your own into a quine.

Write a quine whose first line is a comment with your signature, for example # Ada's quine. The program’s output must match its text character for character, comment included. Reading your own file is not allowed, and neither are the modules inspect and sys or the course module cs.

Run the starter: it prints everything except the first line. Python skips comments, so the program itself has to print the comment, which means the comment has to be in the template.

The template describes the whole program, top to bottom. If the program starts with a comment and a newline, the template must start with them too. Remember that this also changes the written template on the line s = …, but %r will take care of that.

Careful with quotes: if your signature has an apostrophe, as “Ada's” does, repr picks double quotes, and the string in the program has to be written the same way.

The template now starts with the comment and \n, like the program. The substitution %r writes the new, longer template into the line s = … by itself, in double quotes, since the signature has an apostrophe. This is how Thompson’s “baggage” works: you can put as many lines as you like before the line s = …, as long as the template starts with the same lines.

What next

To find out how many times “war” occurs in War and Peace, one line is enough: words.count("war"), and a moment later you have the answer, 292. But which words of the novel are the most frequent? For that you have to count every word. With what we have so far, the only way is the one we used for the countries: a list of the different words and a parallel list of counters. Here is how it fares on the first 10,000, 20,000, 40,000 and 80,000 words.

Every doubling of the text makes the count nearly three times slower. The culprits are in and index: to find out whether a word has come up before, Python walks the whole list of known words, and that list grows with the text. On the whole novel, more than 560,000 words with over 20,000 different ones, the count would take about ten seconds, all the time the sandbox gives a program; on a library of a hundred novels, hours. We need a structure that goes from a word straight to its counter without looking through the others. It is called a dictionary, and with it the whole novel is counted faster than you can blink. In Chapter 8 we’ll use it to count letters, as the inventors of the telegraph did, and find out why the letter E in Morse code is a single dot.