Chapter 8. Thematic text analysis

Topics:

analysing text content, working with dictionaries

Constructs:

dict, for-loop with multiple loop variables

In Chapter 5, we had a first look at text analysis. We gathered various statistics about a text, such as the numbers of characters, words and sentences. This then allowed us to calculate a reading ease score. All the analysis in that project, however, was numeric: it looked at the number of words and sentences, but not at the content of the text. It did not matter what the words were, or what the text was about.

In this chapter, we will go a little deeper into analysing a text by looking at the actual words being used. We will see that a very simple idea can give us a reasonable sense of what a text is about: We can find out what words are being used much more than we would expect from an average text, and these frequently used words can give us a hint at the theme of the document.

This approach can also be taken further: We can build on this and use a similar idea not to analyse, but to generate text. This may be used to predict the next word in a sentence, as is sometimes done in phone texting applications, or even to generate entire sentences or documents, as Large Language Models (LLMs) do.

We will leave the text generation to the next chapter, and first concentrate on analysing the content of a document.

In the process of doing this, we will introduce a very useful new data structure: a dictionary. Through the project in this chapter, we will understand what a Python dictionary is and how we can use it.

8.1. Counting words

We can achieve our content analysis in three steps:

  • First, we count how often each word is used in our text.

  • Secondly, we compare the frequency of each word that was used with the frequency we would expect in an average English text.

  • And thirdly, we find the most-overused words – the ones that were used much more than expected – and print them out.

We will take this entire chapter to complete this task; here, we start at the beginning: counting how often each word is used in a text. This is also known as the frequency of the word.

To start this task, we have prepared a starter project named thematic-analysis, which you can find in the Chapter08 folder in the book projects. This project includes only two helper functions which we will use later, but no code to help us with the initial task.

In previous projects, we have seen how useful it is to use functions in our programs to do specific subtasks. So this time, we will start by writing a function to count the frequency of a given words in a text.

Exercise 8.1   Open the thematic-analysis project. In this project, add a new function named count_word with two parameters: count_word (search_word, text). The purpose of this function is to count how often the word search_word appears in the text text. Initially, leave the body empty, but write a comment describing what the function will do.

Exercise 8.2   Start implementing the function: Split the text into a list of words and store this list in a variable named words. If you have trouble doing this, look back at Chapter 5 for details.

Exercise 8.3   Test your code so far. Do this by printing out your words list inside the count_word function, and adding a test call to this function in the My code section. If all went well, your program should print out a list of all words in the second parameter.

Exercise 8.4   Once you have convinced yourself that the word-splitting works, remove the print statement again. Instead, add a loop to count how often search_word appears in your words list. Remember, you can check whether a given word is the search-word by using the double-equal sign:

Strype code

You will need to use a variable to do the counting.

Exercise 8.5   Return the correct count from your function. That is: your function should return the number of times the search word was found (an integer). Adapt your test code accordingly to receive and print out the result.

Exercise 8.6   Your function should now work. Test your function with different data (different search-words and different sentences). Does your function work correctly?

If you have reached this stage in the project, you will have seen that we can now count the frequency of a given word in a text. You may have noticed, however, that some problems remain that can result in incorrect counting. If not, you should be able to see the problem with the following exercises.

Exercise 8.7   Test your function with the search-word "say" and the text "You say Yes I say No You say Stop and I say Go go go". Then test it with the search-word "go" and the same text. What do you observe?

Why does the test work correctly for the word "say", but not for the word "go"? You have probably worked out that this is because of the uppercase character in one of the "Go" occurrences in the text.

For our purposes, we do not care about the upper- or lowercase nature of the words – we would like to count them in any case. We can easily achieve this by first converting our entire text to lowercase before starting our counting:

Strype code

The lower() method is a built-in method of the string class that returns a new string with all characters converted to lowercase. Note that it does not modify the original string. Thus, we have to assign the result back to our string if we want to keep it.

Exercise 8.8   Look up the documentation for the string method lower(). (You can do this by doing a web search for "Python string lower".) What will happen with numbers in the string?

Exercise 8.9   Research the methods provided in Python for the string class. How many methods are there? What is the name of the method you could use to check whether a string consists only of a number?

Exercise 8.10   What does the string’s count() method do?

With these exercises, you will have discovered two things: First, there are many useful string methods available which can save us a lot of work of writing code ourselves. Knowing what methods are available is important to becoming a good programmer. You do not need to memorise all methods, but you should have a link to the documentation page readily available when you work, so that you can look them up whenever you need them.

And secondly, we can see that a count method is available that is somewhat similar to the count_word function we are currently writing. The reason we are writing our own is two-fold: The built-in count method does not find words, but substrings. Thus, "car", for example, would also be found if the actual word is "scarlet". Also important is our goal to modify and extend this function to count not one word, but all words (and there is no ready-made method for this). So our counting function is a stepping stone for more powerful functionality.

Concept The Python string class has many ready-made library methods that provide useful functionality. It is useful to be familiar with these methods, and to look them up whenever you need to manipulate strings.

Exercise 8.11   Modify your count_word function so that it converts the text to lowercase before searching. Run your test again. Make sure that the last test case given in the earlier exercise returns 3.

Exercise 8.12   Run another test: Count the occurrences of the word "say" in the string "You say, Yes, I say, No. You say, Stop and I say, Go, go, go"

Again, you may have noticed that your function does not count correctly. This time, it is the punctuation characters that are throwing a spanner in the works. When we split the text, we separate the original string at space characters, but all other characters are left in the words. Thus, our list of words contains the word "say," (including the comma), instead of the word "say".

To fix this, we will need to remove the punctuation characters from each word.

Exercise 8.13   Research the strip() method of Python’s string class. What does it do? What are its parameters?

Exercise 8.14   Consider the statement

Strype code

What does this line do? How does it work? What does string.punctuation refer to? What do you have to import to make this work?

Exercise 8.15   Modify your function to strip punctuation characters from the words before comparing them. Test your function again. Make sure all words are now correctly found, even if the original text includes punctuation.

Exercise 8.16   Your project includes the read_file function that we have used in Chapter 5 to read in a contents of a book. We can use the same files again to test our program. Use the read_file function to read in the book "/books/winnie-the-pooh.txt", then use your count_word function to count how often "piglet" is mentioned in the book.

The project with all the changes made so far is available in the book projects as thematic-analysis-v2. If you are unsure about any of your own code, you can compare your project to this one.

Let us pause for a moment and take stock of what we have achieved so far. We can now count the number of occurrences of a given word in any given text. We do this ignoring the case of the letters. That is a good start. But a major problem remains: We would like to count all the words in a text, not only one given word. We know from previous chapters that we can hold a count in a variable, but the challenge here is that we need a count for each different word in the text.

It is not feasible to create a separate variable for each word. There may be thousands of different words, and we do not know in advance how many variables we would need. A list is also not directly suitable because we need to hold two pieces of information: the word, and its count, together.

The solution to this problem lies in a new data structure: a dictionary. In the next section, we discuss what a dictionary is, how we use it, and how it can help us to solve this problem.

8.2. Dictionaries

A dictionary (or dict, as Python calls this type internally) is a data structure that stores key/value pairs.

Every entry in the dictionary associates a key with a value, and we can look up the value by providing the key. An example is a list of phone numbers for your friends (Figure 8.1).

code
Figure 8.1: An example of a dictionary: a phone book

We see that we can think of a dictionary as a table with two columns. The first column holds the keys (here: the names), and the second column holds the values (the phone numbers).

We can then look up the value by providing the key. Let us look at an example how this works. Assume we have the dictionary shown in Figure 8.1 stored in a variable called phonebook. We can then write

Strype code

This will print Adam’s phone number. We use the key like an index in the dictionary, and we receive the associated value. This makes looking up a particular entry in the dictionary very easy.

We can also add more entries to our dictionary. For example

Strype code

will add a new entry in the dictionary (a new row in the table) with the phone number for Zola. Again, we see that we use the intended key like an index in square brackets, and we just assign the value to create the entry.

Dictionaries are useful in many situations, and we are familiar with the concept from our daily lives. One important aspect is that they do not only work with strings. Both the key and the entry may be any type we choose.

For example, your contacts app on your phone holds a dictionary that uses the name of a person as a key and the address details as the value. (Here we can see that the value can be more than a single string: it can itself be a complex data type with several types of information). You use it by looking up the name, and it shows you the associated detail (the value).

Another example is a password file on a computer (associating usernames with passwords). Perhaps the most familiar example is an English language dictionary (from which the Python data structure gets its name): The key here is a word, and the value is its definition. We can find a word in the dictionary to look up its definition.

Concept A dictionary is a data structure that holds key/value pairs. We can look up a key to find the associated value.

The last thing we need to know is how to create a dictionary. Python dictionaries are written with curly brackets: { }. Writing the curly brackets on their own creates an empty dictionary:

Strype code

We can also create a dictionary pre-filled with some data using a colon to separate keys and values, and a comma to separate the entries:

Strype code

Concept Python dictionaries are written using curly brackets: { }. Writing a pair of curly brackets on their own creates an empty dictionary.

Before we go on to use a dictionary in our word counting function, let us do some simple exercises to become more familiar with dictionaries.

Exercise 8.17   Close your current project for the moment and create a new project for these exercises. In your new project, create a variable names phonebook and assign to it an empty dictionary. Print this variable to the console. What is the output?

Exercise 8.18   Add to the phonebook three entries with names and phone numbers of three of your friends or family. Print the phonebook again. What does the output look like?

Exercise 8.19   What does the len function return when you use it to check the length of your phonebook?

Exercise 8.20   What happens when you add an entry with a key that already exists? Experiment with this and then describe what happens.

Exercise 8.21   What happens when you add an entry with a value that already exists? Try this out.

Exercise 8.22   What happens when you try to look up a key that does not exist in the dictionary?

Exercise 8.23   What is the output of the following code:

Strype code

Exercise 8.24   With the same code as in the previous exercise, what is printed by the following line:

Strype code

Exercise 8.25   How do you remove an entry from a dictionary? Do some research to find the answer.

Concept A key in a dictionary is unique, but values can appear repeatedly. Inserting an entry with a key that already exists replaces the existing entry.

8.3. A dictionary of word counts

Let us now get back to the word counting problem in our thematic-analysis project. We were at the point where we could count the frequency of a single word, and we need to figure out how to count the frequencies of all words. As we have stated above, a dictionary can help with this.

The idea is that we use a dictionary – let’s call it counts – with an entry for each word. We can use the word itself as a key, and use the value for the count (the number of times we have found the word).

code
Figure 8.2: A dictionary of word counts

For example, a word count of the words in the sentence

"How many cakes could a good cook cook if a good cook could cook cakes?"

would result in the dictionary shown in Figure 8.2.

In other words: we will create a dictionary with one entry for every unique word in our text, where the value holds the number of times each word was used.

It is useful to remind ourselves that this dictionary – if we do a word count for a whole book – will hold thousands of entries. This, however, is not a problem: computers are good at dealing with large amounts of data.

The algorithm we will use can be informally described as follows:

  • We will convert the text to lowercase and then split it into a list of words as before.

  • We first create an empty dictionary called counts.

  • Then, for every word in our list of words:

    • If the word is already in the dictionary, we increment its count by one.

    • If the word is not yet in the dictionary, we create an entry for it with count 1.

You can modify your existing program to implement the algorithm described above by working step by step through the following exercises.

Exercise 8.26   We no longer wish to search for a single word, but count all words. Therefore, rename the count_word function to count_words and remove the first parameter (search_word).

Exercise 8.27   We leave the first two lines of the function intact (the conversion to lowercase and creating a list of words in the text). We change the next line: instead of creating an integer variable count, we now create an empty dictionary called counts.

Exercise 8.28   We can leave the loop that iterates over all words in the text. We can also leave the code that strips the punctuation from the word.

Exercise 8.29   We need to change our if-statement to do what the algorithm description above indicates: We need to check whether the word is already in our dictionary. We can do this using the in operator to check:

Strype code

Exercise 8.30   We need to fill in the if-part of the if-statement: In this case, we need to increment the existing count for the word. We do this by reading the value for the word, adding 1, and writing it back into the dictionary:

Strype code

Exercise 8.31   We then fill the else part with code to create a new entry for the word, with the value 1.

Exercise 8.32   At the end of the function, we now need to return the dictionary counts.

Exercise 8.33   Do not forget the comment: Modify the function comment to describe what your function now does.

Exercise 8.34   Test your function by modifying the code that calls it in the "My code" section. First, use a short test sentence, and print out the resulting word count dictionary.

Exercise 8.35   Now test the function on a whole book. Use, for example, the "/books/winnie-the-pooh.txt" file to count all the words in that book. Print out the dictionary.

A version of the project that implements these changes is available fo you to study in the project folder as thematic-analysis-v3.

Concept We can check whether a given key is present in a dictionary by using the in operator, like this: if key in dict. The in operator will return True or False.

Exercise 8.36   Look up what methods are available for Python’s dictionary class. What is the name of the method that returns a list of the keys in a dictionary? What is the name of the method returning only the values? What is the name of the methods that lets you delete an entry with a given key?

8.4. Iterating over dictionaries

We have seen that printing out the dictionary using a simple print call works, but the output is not very readable. The list of word counts is very long, and the formatting is not very readable. Let us improve this by implementing a custom printing function for our word count table.

Exercise 8.37   Create a new function called print_dict(dict) in your program. Its purpose is to print out a dictionary in a nicer layout. Write an appropriate function comment.

Exercise 8.38   In the body of this function, have a first try at printing out the dictionary entries. Test your function.

A first attempt at printing out the entries in our dictionary might involve printing each entry on a separate line:

Strype code

Exercise 8.39   Implement and test the print_dict version above. What do you observe?

With the above exercise, we can see that this version prints out the words in the dictionary, but not the counts. In other words: it prints the keys, but not the values of the dictionary.

How does this happen? Let us step back and take another look at the methods from the dictionary class that allow us to access its entries. There are three methods that give us lists of the dictionary contents:

Method Description

keys()

Returns a list containing the dictionary’s keys

values()

Returns a list containing the dictionary’s values

items()

Returns a list of tuples of the dictionary entries, where every tuple contains a key: value pair.

The first two of these show us that we can get a list containing just the keys or the values. This means that we could print out either the keys or the values, line by line, using these two loops:

Strype code
Strype code

Try this out. What we observed with the last exercise above is the following: If we just iterate over dict, without stating whether we want to get the keys or the values, we will get the keys by default.

Let us look at the third method: items(). This method states that it returns a tuple with the key and value. A tuple is another of Python’s collection types. It can hold multiple arbitrary data items to group them together. We will discuss tuples in more detail in the next chapter; for now it is sufficient to know that it consists of multiple data items and is written using round brackets:

( "Let It Be", "The Beatles", 1970 )

When we think about our dictionary, we can view a dictionary as a list of tuples. The first two entries of the dictionary shown in Figure 8.2, for example, can be regarded as two tuples:

( "how", 1 )
( "many", 1 )

Let us now try to use the items() method in our loop:

Strype code

Try this out. You will see that this loop prints each entry of the dictionary as a tuple.

Exercise 8.40   Implement and test the loop using the items() method.

This is, so far, the most useful method for us: it allows us to get the full data (key and value), and print them out line by line. But we can get even more control using an additional feature of the for-loop.

If we use a Python for-loop to iterate over a collection, and the item type of the collection is a tuple, then the loop can contain multiple loop variables:

Strype code

The rules for this type of for-loop are quite straight forward:

  • The loop can provide a comma-separated list of loop variables.

  • Each item of the collection we iterate over must itself be a collection.

  • The number of loop variables must match the number of data items in each collection item. (In our case: Since each tuple in dict.items() holds two pieces of data, we need exactly two loop variables.)

  • The individual data items of each collection entry will be assigned to the loop variables for each iteration.

Concept We can iterate over the entries in a dictionary by using the dict.items() method to get a list of all entries.

Concept A for-loop can have multiple loop variables. In that case, the collection it iterates over must itself hold collections (tuples, sets or lists) of a matching length.

Using this variant of the for-loop allows us to get access to each key and value individually, and we are free to format the output ourselves. In the code example above, we have done this using an f-string.

Exercise 8.41   Implement this latest version of the for-loop. Test it with one of the full book texts as input.

Our output is now more nicely formatted. It is, however, still very long, and identifying frequently used words is still tricky. Change this by doing the following exercises.

Exercise 8.42   Change your print_dict function so that it prints out entries only if the word count is greater than 30.

The list is now much shorter, but it still contains a lot of very common words, such as "a", "to" and "the" – words that do not tell us much about what the text is concerned with. Let’s do a first attempt at identifying interesting words by dropping all short words.

Exercise 8.43   Modify your print_dict function so that words shorter than five characters are ignored.

We can see that we are getting a bit closer. If you run this version of your program on one of the book texts provided, you can see that a list of words slowly emerges that may characterise the book. But the rules we have used to select the words (filtering by a word count that is more than a fixed value, and by length of the word) are not really good enough.

What we need to do instead is not look at the absolute frequency of a word (the number of times it appeared), but look for words that appear more often than they would in a typical English text. The word "the", for example, may appear very often, but it does so in all English writing, so this does not make it an especially interesting word.

This will be our goal in the next section: We will use existing statistics about expected frequencies of words in the English language to identify "over-used" words in our text: words that are used more often in this text than they are used in average English texts.

8.5. Over-used words

In our project, we have provided a helper function called get_expected_frequencies(). This function returns a dictionary that lists the expected frequencies for the most common words in an average English language text. (It uses a dictionary of about 64,000 words.)

code
Figure 8.3: A dictionary of expected word frequencies

The dictionary has a word as its key, and the expected frequency as its value (Figure 8.3). The frequency is the ratio of occurrences of the word relative to the length of the text. It can be computed by dividing the word count by the total number of words in a text:

code

Using this formula, we can easily calculate the frequency of each word in the text we wish to analyse and then compare it to the expected frequency. Those words that are used far more often than expected are the "interesting words" – the words that might give us a clue to what the text is about.

Let us go ahead and implement this idea. We will identify the frequent words, which we define as words with a frequency that is at least 10 times the expected frequency and which were used at least 10 times.

Exercise 8.44   Add a new function called get_overused_words that takes three parameters: an observed_counts dictionary of word counts in the text under investigation, the total number of words in our text (total_words), and an frequencies dictionary of word frequencies in the English language:

Strype code

Add an appropriate comment.

Exercise 8.45   Add a loop inside the function that iterates over the entries in the observed word list. We can use the pattern with two loop variables, as discussed above:

Strype code

Exercise 8.46   Inside the loop, add an if statement that checks if the word is in the frequencies dictionary. If it is, compute the actual frequency of the word in the text (count / total_words) and store it in a variable.

Exercise 8.47   At the beginning of your function, create an empty dictionary called frequent. This will be used to collect the frequent words and their word counts. Inside the loop, add each word to this dictionary if it is used at least 10 times, and if its frequency is at least 10 times the expected frequency. At the end of your function, return this dictionary.

Exercise 8.48   In the "My code" section, add code to call and test this function. To do this, you will need to calculate the total number of words on your text. You can do this easily as follows:

Strype code

Print out the dictionary of frequent words.

With the code so far, we can print out the dictionary of frequent words. We have, however, no direct control over the exact number of words we will receive. This will depend on the length of the actual text and the words within it.

Let us make one last improvement to specify exactly how many words we wish to see. To do this, we will sort the frequent words by their word count, and then select the top words from this list.

We can do this by making good use of Python’s built-in sorted function:

Strype code

We can add this statement to our get_overused_words function to create a list of the frequent words, sorted by their word count.

This is quite an advanced use of this function, so let us investigate that line in some detail.

  • We use the built-in sorted function. This function takes a list and returns a sorted copy of that list. (It is important to note that it does not sort the original list, but returns a sorted copy.)

  • We pass a dictionary to this function, which expects a list. If this function receives a dictionary, it will sort just the keys from the dictionary.

  • The sorted function allows us to specify a key as a parameter. The key is a function that is used to sort the list. Here, we use frequent.get as the key; this is the dictionary method that returns the value from the dictionary, thus the keys will be sorted by their associated value.

  • We set the reverse parameter to True to sort the list in descending order, with the highest values first.

Concept The sorted function returns a sorted copy of a list. It has optional parameters to specify a sort function and the sort order.

After doing this, we can use a slice to take the first few elements. For example

Strype code

will give us the 12 most frequent words. We can then return this list from our function.

Exercise 8.49   Make the changes discussed here to your code. Add another parameter to your get_overused_words function to specify how many words you wish to receive. Note that the function now returns a list, not a dictionary! You will need to modify your test code accordingly.

Exercise 8.50   Format the output of your program nicely, so that it prints the title of the book you are evaluating (or the name of the file), and then the most over-used words as identified by your function.

In making these changes, it is likely that you ran into a number of errors. This is to be expected. We have made many changes, and we will always make errors along the way. It is not the goal to write our code error-free at the first attempt, but to get to know Python well enough that we can identify and fix any errors that occur. Remember, you can refer to Appendix C: Dealing with Errors to get some more information about errors and error messages.

Exercise 8.51   Try your code on the other books provided in Strype’s "/books" folder. (They include, for example, "/books/three-men-in-a-boat.txt" and "/books/war-and-peace.txt". See strype.org/doc/filesystem for a complete list). Also try some text you have written yourself, such as a story or essay (we explained how to do this in Chapter 5).

Once you manage to complete these tasks, you will see that you can now get a vague impression what a book is about. You can see that War and Peace is about a prince, a princess, an emperor and Napoleon, or that Three Men in a Boat is about a boat, a picture, locks and rowing.

A version of this project that includes all the changes discussed here is available as thematic-analysis-v4; you may like to compare your own version to this one.

8.6. Summary

In this chapter, we have learned how to use dictionaries to achieve more advanced functionality.

Dictionaries are perhaps a little more unusual than lists, but they are equally useful. To become a good programmer, it is important to become familiar with dictionaries, and fluent in using them. They should be one tool in your standard toolbox, so that you can use them whenever they can help to solve a problem. You will see that they come in handy more often than you initially expect.

Concept summary

  • The Python string class has many ready-made library methods that provide useful functionality. It is useful to be familiar with these methods, and to look them up whenever you need to manipulate strings.

  • A dictionary is a data structure that holds key/value pairs. We can look up a key to find the associated value.

  • Python dictionaries are written using curly brackets: { }. Writing a pair of curly brackets on their own creates an empty dictionary.

  • A key in a dictionary is unique, but values can appear repeatedly. Inserting an entry with a key that already exists replaces the existing entry.

  • We can check whether a given key is present in a dictionary by using the in operator, like this: if key in dict. The in operator will return True or False.

  • We can iterate over the entries in a dictionary by using the dict.items() method to get a list of all entries.

  • A for-loop can have multiple loop variables. In that case, the collection it iterates over must itself hold collections (tuples, sets or lists) of a matching length.

  • The sorted function returns a sorted copy of a list. It has optional parameters to specify a sort function and the sort order.


1. https://shakespeareoxfordfellowship.org/shakespeare-by-the-numbers-what-stylometrics-can-and-cannot-tell-us