Python: Simplifying the creation of a stop word list with defaultdict
Date: 2015-03-22 01:51:52

I've been playing around with topics models again and recently read a http://www.perseus.tufts.edu/publications/02-jocch-mimno.pdf[paper by David Mimno] which suggested the following heuristic for working out which words should go onto the stop list:

____
A good heuristic for identifying such words is to remove those that occur in more than 5-10% of documents (most common) and those that occur fewer than 5-10 times in the entire corpus (least common).
____

I decided to try this out on the https://github.com/mneedham/neo4j-himym/blob/master/data/import/sentences.csv[HIMYM dataset] that I've been working on over the last couple of months.

I started out with the following code to build a dictionary of words, their total occurrences and the episodes they'd been used in:

[source,python]
----

import csv
from sklearn.feature_extraction.text import CountVectorizer
from collections import defaultdict

episodes = defaultdict(str)
with open("sentences.csv", "r") as file:
    reader = csv.reader(file, delimiter = ",")
    reader.next()
    for row in reader:
        episodes[row[1]] += row[4]

vectorizer = CountVectorizer(analyzer='word', min_df = 0, stop_words = 'english')
matrix = vectorizer.fit_transform(episodes.values())
features = vectorizer.get_feature_names()

words = {}
for doc_id, doc in enumerate(matrix.todense()):
    for word_id, score in enumerate(doc.tolist()[0]):
        word = features[word_id]
        if not words.get(word):
            words[word] = {}

        if not words[word].get("score"):
            words[word]["score"] = 0
        words[word]["score"] += score

        if not words[word].get("episodes"):
            words[word]["episodes"] = set()

        if score > 0:
            words[word]["episodes"].add(doc_id)
----

This works fine but the code inside the last for block is ugly and most of it is handling the case when parts of a dictionary aren't yet initialised which is defaultdict territory. You'll notice I am using defaultdict in the first part of the code but not yet the second as I'd struggled to get it working.

This was my first attempt to make the 'words' variable based on it:

[source,python]
----

>>> words = defaultdict({})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: first argument must be callable
----

We can see why this doesn't work if we try to evaluate '{}' as a function which is what defaultdict does internally:

[source,python]
----

>>> {}()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'dict' object is not callable
----

Instead what we need is to pass in 'dict':

[source,python]
----

>>> dict()
{}

>>> words = defaultdict(dict)

>>> words
defaultdict(<type 'dict'>, {})
----

That simplifies the first bit of the loop:

[source,python]
----

words = defaultdict(dict)
for doc_id, doc in enumerate(matrix.todense()):
    for word_id, score in enumerate(doc.tolist()[0]):
        word = features[word_id]
        if not words[word].get("score"):
            words[word]["score"] = 0
        words[word]["score"] += score

        if not words[word].get("episodes"):
            words[word]["episodes"] = set()

        if score > 0:
            words[word]["episodes"].add(doc_id)
----

We've still got a couple of other places to simplify though which we can do by defining a http://pymotw.com/2/collections/defaultdict.html[custom function] and passing that into defaultdict:

[source,python]
----

def default_dict_function():
   return {"score": 0, "episodes": set()}

>>> words = defaultdict(default_dict_function)

>>> words
defaultdict(<function default_dict_function at 0x10963fcf8>, {})
----

And here's the final product:

[source,python]
----

def default_dict_function():
   return {"score": 0, "episodes": set()}
words = defaultdict(default_dict_function)

for doc_id, doc in enumerate(matrix.todense()):
    for word_id, score in enumerate(doc.tolist()[0]):
        word = features[word_id]
        words[word]["score"] += score
        if score > 0:
            words[word]["episodes"].add(doc_id)
----

After this we can write out the words to our stop list:

[source,python]
----

with open("stop_words.txt", "w") as file:
    writer = csv.writer(file, delimiter = ",")
    for word, value in words.iteritems():
        # appears in > 10% of episodes
        if len(value["episodes"]) > int(len(episodes) / 10):
            writer.writerow([word.encode('utf-8')])

        # less than 10 occurences
        if value["score"] < 10:
            writer.writerow([word.encode('utf-8')])
----