Module 10 · Capstone Project Milestones ⏱ 19 min

Capstone: Build a Text Analyzer

By the end of this lesson you will be able to:
  • Decompose a small project into functions that each do one job
  • Combine functions, dicts, and comprehensions to analyze text
  • Assemble the pieces into a finished tool that reports word stats
  • Handle edge cases like empty input gracefully

Time to put the pieces together. You will build a small but real tool: a text analyzer that counts words and finds the most common one. It reuses everything you have learned — functions, dicts, and the engineering habit of decomposing first.

The plan, as milestones:

  1. count_words(text) — build a word-frequency dict.
  2. top_word(freq) — find the most common word.
  3. word_count(text) — total number of words.
  4. analyze(text) — assemble them into a finished report.

Build each piece, test it, then wire them together. That is how real software gets written. No single function is longer than five lines, but together they do something useful. This lesson is about composition: making small, testable pieces and snapping them together.

flowchart LR
  T["raw text"] --> S["split into words"]
  S --> D["build freq dict"]
  D --> R["report: total + top word"]
  style D fill:#3776ab,color:#fff
  style R fill:#3776ab,color:#fff
The analyzer pipeline: split text into words, tally into a dict, then report.

Why decomposition matters

A beginner writes one long function that does everything. When it breaks, you have no idea which part failed. A professional breaks the job into single-purpose functions, tests each one, then composes them.

The test for a good function is simple: can you describe what it does in one sentence without using the word 'and'? count_words builds a frequency table. top_word finds the maximum. analyze calls both and formats the result. Each has one reason to change, one job to do, and one set of tests that prove it works.

This habit pays off immediately. If your frequency table is wrong, you know the bug lives in count_words. If the report format changes, only analyze needs editing.

When to stop decomposing

Not every line deserves its own function. The right size is the size you can test in your head. word_count is one line, but it is still worth extracting because it has a clear name and a clear test. If a function is two lines and those lines are inseparable, leave them inline.

The warning sign of over-decomposition is a function whose name is longer than its body. The warning sign of under-decomposition is a function you cannot describe in one sentence. When in doubt, split — it is easier to inline a function later than to untangle a monster.

Milestone 1 — count the words

Split the text on whitespace and tally each word into a dict. This is the frequency table everything else reads from.

def count_words(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    return freq

text.split() breaks on any whitespace and returns a list of words. The loop walks that list and uses freq.get(word, 0) + 1 to increment the count. get is the safe way to read a dict: if the key is missing, it returns 0 instead of raising a KeyError. Without get, you would need an if word in freq: check every time, which is longer and harder to read.

count_words builds a frequency dict from any text string.
def count_words(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    return freq

print(count_words('the cat the dog the bird'))
print(count_words('hello world hello'))
print(count_words(''))
flowchart LR
  T["'the cat the'"] --> S["split"]
  S --> D["freq = {}"]
  D --> A["'the' → 1"]
  A --> B["'cat' → 1"]
  B --> C["'the' → 2"]
  style D fill:#3776ab,color:#fff
Each word is looked up in the dict, incremented, and stored back.

Milestone 2 — find the top word

Given the frequency dict, walk it once and keep track of the word with the highest count. On a tie, keep the first one you found.

def top_word(freq):
    best = None
    for word, count in freq.items():
        if best is None or count > freq[best]:
            best = word
    return best

The loop uses freq.items() to get both the word and its count at the same time. best starts as None so the first word always wins the initial comparison. The strict greater-than count > freq[best] means that when two words have the same count, the first one stays in best. This is a small detail, but determinism matters in tests and in user-facing output.

top_word finds the most common entry, with first-winner ties.
def top_word(freq):
    best = None
    for word, count in freq.items():
        if best is None or count > freq[best]:
            best = word
    return best

print(top_word({'a': 2, 'b': 1}))
print(top_word({'x': 3, 'y': 3, 'z': 1}))
print(top_word({}))

Milestone 3 — total word count

A one-liner: the total number of words is just how many items text.split() produces.

def word_count(text):
    return len(text.split())

Milestone 4 — assemble the tool

Now compose the pieces. analyze calls the helpers you just built and returns a short human-readable summary. This is the payoff of decomposition: the final function reads like the spec.

def analyze(text):
    freq = count_words(text)
    total = sum(freq.values())
    top = top_word(freq)
    return f'{total} words; most common: {top}'

Notice that total is computed with sum(freq.values()) rather than calling word_count. Both are correct, but reusing the dict you already built is slightly more efficient and keeps the data flow clear.

flowchart LR
  C["count_words"] --> D["freq dict"]
  D --> A["analyze"]
  A --> R["report string"]
  style C fill:#3776ab,color:#fff
  style A fill:#3776ab,color:#fff
Small functions feed into analyze, which formats the final report.
The finished analyzer — three small functions composed into one tool.
def count_words(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    return freq

def top_word(freq):
    best = None
    for word, count in freq.items():
        if best is None or count > freq[best]:
            best = word
    return best

def analyze(text):
    freq = count_words(text)
    total = sum(freq.values())
    top = top_word(freq)
    return f'{total} words; most common: {top}'

print(analyze('the cat the dog the bird'))

Handling edge cases

Real tools must behave when given empty or unusual input. An empty string has no words, so count_words('') returns {} and top_word({}) returns None. A string with only whitespace splits into an empty list, so word_count(' ') returns 0.

These cases are easy to forget and painful to debug later, because they only appear in production when a user submits a blank form. The defensive approach is to write a test for the empty case the moment you define the function. If your code handles {} and '' correctly, it will handle almost everything else.

Exercise

Write count_words(text) that splits text on whitespace and returns a dict mapping each word to the number of times it appears.

def count_words(text):
    # split text, then build a word -> count dict
    pass
Exercise

Write top_word(freq) that takes the dict from count_words and returns the word with the highest count. Return None for an empty dict. On a tie, return whichever word comes first.

def top_word(freq):
    # track the best word seen so far
    pass
Exercise

Write word_count(text) that returns the total number of words in text (the length of text.split()).

def word_count(text):
    # one expression is enough
    pass
Exercise

What does this print? It counts words and sums their frequencies.

def count_words(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    return freq

print(sum(count_words('one two two three three three').values()))
Exercise

Bring the pieces together. count_words and top_word are given to you below — each exercise runs on its own, so a lesson's exercises can't see each other's code. Write analyze_with_stats(text) that uses them to return a dict with keys 'total', 'top_word', and 'unique_count'. For empty text return {'total': 0, 'top_word': None, 'unique_count': 0}.

def count_words(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    return freq


def top_word(freq):
    best = None
    for word, count in freq.items():
        if best is None or count > freq[best]:
            best = word
    return best


def analyze_with_stats(text):
    # your code here
    pass

Recap

  • Decompose a project into single-purpose functions that each do one job.
  • Use a dict with get for frequency tables — it is fast and readable.
  • Compose small functions into larger ones; the final code reads like the spec.
  • Handle edge cases like empty strings explicitly.
  • Test each piece in isolation before wiring them together.

You have now written a real tool. The same process — decompose, implement, test, compose — scales to programs thousands of lines long.

Checkpoint quiz

Why split the analyzer into count_words and top_word instead of one big function?

Given count_words('a b a'), what does the returned dict look like?

Go deeper — technical resources