リスト、辞書、集合、ループと内包表記
Python の4つのコンテナの使い分け、ループと条件分岐、内包表記、そして乱雑なチケットの単語を数えて、毎回同じになるトップ5を出す方法。
どうやって学びますか?
解決する問題
サポート部門の責任者は、今週のチケットでお客様が最も多く不満を述べた内容を知りたいと考えています。チケットの文章は、まとまりのないテキストです。大文字で書かれたものもあれば、句読点だらけのものもあり、同じことを2回書いているものもあります。同僚が5分ほどでスクリプトを書き、リストを送ってくれました。先頭は the、次が refund、そして Refund と refund. が別々の項目として並んでいました。
2回目の実行では、別の問題が起きました。チケットの順番をほんの少し入れ替えただけで、上位5語の5番目の単語が変わってしまったのです。どの単語の件数も変わっていないのに、です。レポートを読む人には、どちらのバージョンが正しいのか判断できませんでした。
答えは特別なライブラリではありません。Python の基本的なコンテナ4種類、つまりリスト、タプル、辞書、集合と、ループと条件分岐を正しく使うことです。AI エンジニアの日々の仕事の大きな部分は、まさにこれです。テキストを整え、数え、絞り込み、並べ替えること。しかも同じ入力に対して、毎回同じ結果が得られるようにすることです。
この章を終える頃には
- 用途に応じて、リスト、タプル、辞書、集合のどれを使うかを選び、その理由を説明できる
for、while、if/elif/else、enumerate、.items()を使って、読みやすいループを書ける- リスト、辞書、集合の内包表記で、データを絞り込んだり変換したりできる
- 乱雑なテキストから単語を取り出し、辞書と
Counterの両方で数えられる - 件数が同じ単語を、明確なルールで並べ替え、毎回同じ結果になるようにできる
前提条件: pathlib、JSON/YAML 設定、環境変数 — そして uv run でスクリプトを実行できること。
4つのコンテナ、4つの役割
まずは4つを並べて見てみましょう:
tickets = ["refund", "login", "refund", "billing"]
latest = ("T-1042", "refund", 3)
owners = {"refund": "Maya", "login": "Omar"}
seen = set(tickets)
print(tickets[0], tickets[-1], len(tickets))
print(latest[1])
print(owners["login"])
print(sorted(seen))
print("login" in seen, "shipping" in seen)refund billing 4
refund
Omar
['billing', 'login', 'refund']
True Falseそれぞれ異なる「約束」を持っています。その約束を基準に選びましょう:
- リスト(
[...])— 順序を保ち、同じ値を複数回入れられ、後から変更できます。「順番に並ぶ一連のもの」向けです。チケットの一覧や、プロンプトのまとまりなどが該当します。 - タプル(
(...))— 順序を保ちますが、作った後は変更できません。いくつかの値がまとまって1つのレコードになる場合に使います。たとえば("T-1042", "refund", 3)のように、ID、カテゴリ、優先度を並べたものです。ここでは位置が意味を持ちます。 - 辞書(
{key: value})— キーを使って値を引きます。「どのカテゴリを誰が担当しているか」「各単語が何回出てきたか」など、あるものから別のものへたどる場面で使います。 - 集合(
set(...)または{a, b})— 各値は1回だけ入り、順序は保証されません。知りたいことが「それがあるかどうか」や「異なる値がいくつあるか」だけの場合に使います。
sorted(seen) と書いて表示しているのは、集合を表示したときの順序が保証されないからです。実行するたびに違う順番で表示されることもあります。プログラムの出力が集合の順序に依存していると、その出力も安定しません。
リストでinを使うと、要素を1つずつ調べます。集合や辞書では、要素が10個でも1000万個でも、ほぼ一瞬で答えが返ります。if word in stop_wordsを何度も繰り返すなら、stop_wordsはリストではなく集合にすべきです。その理由と、差を測る方法は、このコースで後ほど扱います。
ループ: 何を回しているのかをはっきりさせる
タプルや辞書をループするときは、アンパッキングを使うと最も読みやすくなります。値が直接、名前のついた変数に入ります:
ticket_id, category, priority = ("T-1042", "refund", 3)
print(ticket_id, category, priority)
owners = {"refund": "Maya", "login": "Omar"}
for category, owner in owners.items():
print(f"{category} -> {owner}")
for position, category in enumerate(["refund", "login"], start=1):
print(position, category)T-1042 refund 3
refund -> Maya
login -> Omar
1 refund
2 login.items() は毎回 (key, value) のペアを返し、enumerate は各要素とその位置を一緒に返します。range(len(...)) で添字を数える必要はありません。Python 3.7 以降、辞書はキーを挿入された順序で覚えているので、owners のループは refund から始まります。
.items() を書き忘れるとどうなるでしょうか?
owners = {"refund": "Maya", "login": "Omar"}
for category, owner in owners:
print(category, owner)Traceback (most recent call last):
File "main.py", line 2, in <module>
for category, owner in owners:
^^^^^^^^^^^^^^^
ValueError: too many values to unpack (expected 2)辞書を直接ループすると、得られるのはキーだけです。最初のキー "refund" は6文字の文字列で、Python はそれを2つの変数に分けようとします。6文字に対して変数は2つです。文字列もシーケンスの一種だと思い出すまでは、このメッセージは奇妙に見えるかもしれません。
if/elif/else は、最初に真になった条件で止まります。そのため、条件の順序そのものがロジックになります:
latencies_ms = [120, 340, 95, 1800, 210, 2600]
for latency in latencies_ms:
if latency >= 2000:
label = "critical"
elif latency >= 1000:
label = "slow"
else:
label = "ok"
print(latency, label)120 ok
340 ok
95 ok
1800 slow
210 ok
2600 critical条件を逆の順序で書いてみましょう。つまり>= 1000を先に、>= 2000を後に書くのです。エラーは出ませんが、2600はslowになってしまいます。最初の条件が真になり、チェーンがそこで止まるからです。criticalの分岐は二度と実行されません。しきい値で分けるときは、最も厳しい条件を先に書きましょう。
for は既知のコレクションを回すときに使います。while は、条件が成り立つ間ずっと回ります。何回必要か事前に分からないときに使います。API からページごとにデータを取得するのが典型例です。ここでは辞書が API の役割を果たしています:
pages = {1: ["t1", "t2"], 2: ["t3"], 3: []}
page = 1
collected = []
while True:
batch = pages[page]
if not batch:
break
collected.extend(batch)
page += 1
print(collected, "pages read:", page - 1)['t1', 't2', 't3'] pages read: 2空のページに当たったら break します。if not batch が機能するのは、空のリストが偽とみなされるからです。このようなループでは、停止条件を先に書き、残りをその後に書きましょう。停止し忘れた while True は、エラーも出さずに永遠に回り続けます。
内包表記: あるコレクションから別のコレクションを作る
「空のリストを用意して、ループし、if で選び、append する」。この4行のパターンはとてもよく使われるので、Python には1行で書ける形が用意されています:
latencies_ms = [120, 340, 95, 1800, 210, 2600]
slow = [ms for ms in latencies_ms if ms >= 1000]
seconds = [ms / 1000 for ms in latencies_ms]
labels = {ms: ("slow" if ms >= 1000 else "ok") for ms in latencies_ms}
buckets = {ms // 1000 for ms in latencies_ms}
print(slow)
print(seconds)
print(labels)
print(sorted(buckets))[1800, 2600]
[0.12, 0.34, 0.095, 1.8, 0.21, 2.6]
{120: 'ok', 340: 'ok', 95: 'ok', 1800: 'slow', 210: 'ok', 2600: 'slow'}
[0, 1, 2]読み方は [結果 for 要素 in コレクション if 条件] です。左側は、各要素から何を作るかを示します。末尾の if は、どの要素を残すかを示します。同じ形で {key: value for ...} と書けば辞書が、{value for ...} と書けば集合ができます。buckets には、1秒単位のバケットが3つだけ、それぞれ1回ずつ入っています。
2種類の if を混同しないようにしましょう。末尾の if ms >= 1000 は絞り込みです。条件が偽になると、その要素は捨てられます。一方 "slow" if ms >= 1000 else "ok" は選択です。すべての要素が残り、その値は2つのうちのどちらかになります。だから labels には6つすべてが入っています。
内包表記は、新しいコレクションを作るためのものです。何かを表示したり、ファイルに書いたりするだけで、結果を捨ててしまうのであれば、普通の for ループを書きましょう。また、3段の入れ子の内包表記は1行に収まるかもしれませんが、5行のループの方が同僚にとって読みやすいことも多いものです。
単語を数える: 手作業から Counter まで
辞書で数えようとすると、ほとんどの人は最初に次のように書きます:
words = ["refund", "late", "refund", "login", "refund", "late"]
counts = {}
for word in words:
counts[word] += 1Traceback (most recent call last):
File "main.py", line 5, in <module>
counts[word] += 1
~~~~~~^^^^^^
KeyError: 'refund'counts[word] += 1 は counts[word] = counts[word] + 1 と同じ意味です。右側の counts[word] を読むと、キー "refund" がまだ存在しないことが分かります。初めて見る単語には、最初の値が必要です。.get(key, 0) がまさにそれを用意してくれます:
words = ["refund", "late", "refund", "login", "refund", "late"]
counts = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
print(counts){'refund': 3, 'late': 2, 'login': 1}この作業はとても一般的なので、標準ライブラリにはそのための型が用意されています。collections.Counter という、辞書の一種です:
from collections import Counter
words = ["refund", "late", "login", "refund", "login", "late", "slow"]
counts = Counter(words)
print(counts)
print(counts.most_common(2))
print(counts["missing"])Counter({'refund': 2, 'late': 2, 'login': 2, 'slow': 1})
[('refund', 2), ('late', 2)]
0Counter で存在しないキーを読むと、KeyError ではなく 0 が返ります。数えるという目的にはまさに正しい動きです。ただし most_common(2) の結果を見てください。refund、late、login の件数はどれも2ですが、返ってきたのは最初の2つだけです。なぜ login は落とされたのでしょうか? それは、件数が同じ場合、most_common は単語が最初に現れた順序を保つからです。login はリストの中で3番目に初めて現れたので、負けてしまいました。チケットの順序を変えれば、結果も変わります。これが、この章の冒頭にあった謎の正体です。
テキストの整形: 本当に単語と言えるものは?
split() は空白でしか区切りません:
text = "Refund not received. REFUND please! Login fails; login page is slow."
print(text.split())
print(text.lower().split())['Refund', 'not', 'received.', 'REFUND', 'please!', 'Login', 'fails;', 'login', 'page', 'is', 'slow.']
['refund', 'not', 'received.', 'refund', 'please!', 'login', 'fails;', 'login', 'page', 'is', 'slow.'].lower() で大文字と小文字の違いは解消されましたが、received.、please!、slow. にはまだ句読点が残っています。数えるときには、slow. と slow は別の単語になってしまいます。ここで正規表現が役に立ちます。捨てるものを指定する代わりに、単語がどんな形をしているかを指定するのです:
import re
text = "Refund not received. REFUND please! Login fails; login page is slow. Can't pay."
words = re.findall(r"[a-z']+", text.lower())
print(words)['refund', 'not', 'received', 'refund', 'please', 'login', 'fails', 'login', 'page', 'is', 'slow', "can't", 'pay'][a-z']+ は「1文字以上の英小文字またはアポストロフィ」を意味します。re.findall は、パターンに一致した箇所をすべて返します。それ以外のもの(句点、カンマ、数字)は捨てられます。can't は1つの単語のまま残ります。
このパターンには穴があります。たとえば'invalid token'と書かれていると、最初の単語は'invalidになり、引用符がくっついてしまいます。次の例では、アポストロフィが2文字の間にある場合だけ認めるようにパターンを厳しくします。また、[a-z]は英字だけを表すことも覚えておきましょう。ほかの文字体系のテキストには、別のパターンが必要です。
同じ件数: 並べ替えのルールは自分で決める
件数をランキングにするには、並べ替えが必要です。refund と login がどちらも件数3の辞書を使って、3通りの方法を見てみましょう:
counts = {"refund": 3, "late": 2, "login": 3, "slow": 1}
print(sorted(counts, reverse=True))
print(sorted(counts.items(), key=lambda item: item[1], reverse=True))
print(sorted(counts.items(), key=lambda item: (-item[1], item[0])))['slow', 'refund', 'login', 'late']
[('refund', 3), ('login', 3), ('late', 2), ('slow', 1)]
[('login', 3), ('refund', 3), ('late', 2), ('slow', 1)]1行目は、いちばん危険です。妥当に見えるからです。辞書を並べ替えると、並ぶのは値ではなくキーです。つまりこれはアルファベットの逆順であり、件数とは何の関係もありません。slow は1回しか出現していないのに、先頭に来ています。エラーは出ません。
2行目は件数で並べ替えています。しかし refund と login のどちらが先に来るのでしょうか? Python の sorted は「安定」しています。つまり、キーが等しい要素は、入力での順序をそのまま保ちます。refund の方が先に辞書に挿入されているので、先頭に来ます。言い換えると、結果はデータが届いた順序によって決まってしまうのです。
3行目は、ルールそのものを明示しています。key はタプルを返し、タプルは要素ごとに比較されます。まず -item[1] は件数の負の値で、件数が多いものほど先に来ます。同点の場合は item[0]、つまり単語そのものをアルファベット順に比べます。これで、入力の順番がどうであっても結果は同じになります。reverse=True を書く必要もありません。マイナス記号が件数の順序をすでに逆にし、アルファベット順はそのままにしているからです。
レポート、テスト、評価など、結果を比べる場面では、同じ値どうしの順序を決めるための明確なルールを必ず与えてください。そうしないと、今日のレポートと明日のレポートが、データの違いとは関係のない部分で食い違ってしまいます。
完全な例
ここまでの内容をすべてまとめて、チケットの上位5語を求めましょう。ストップワード(the、and のように、どんな文章にも出てくるけれど意味のない単語)は除外し、2文字以下の単語も除外し、同点はアルファベット順で決めます。top_words.py:
import re
from collections import Counter
STOP_WORDS = {
"a", "and", "for", "i", "is", "it", "my", "not", "of", "on", "the", "to", "was", "with",
}
TICKETS = [
"Refund not received for order 1182. I was charged twice!",
"The app is slow and the login page freezes.",
"Charged twice again. Please refund the second charge.",
"Login fails with 'invalid token' on my phone.",
"Still waiting on my refund, it was promised last week.",
"App slow after the update; login takes a minute.",
]
def tokenize(text: str) -> list[str]:
return re.findall(r"[a-z]+(?:'[a-z]+)?", text.lower())
def top_words(texts: list[str], n: int = 5) -> list[tuple[str, int]]:
counts = Counter(
word
for text in texts
for word in tokenize(text)
if word not in STOP_WORDS and len(word) > 2
)
ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
return ranked[:n]
def print_ranking(ranking: list[tuple[str, int]]) -> None:
if not ranking:
print("no words to rank")
return
width = max(len(word) for word, _ in ranking)
for rank, (word, count) in enumerate(ranking, start=1):
print(f"{rank}. {word:<{width}} {count} {'#' * count}")
if __name__ == "__main__":
print_ranking(top_words(TICKETS))
print_ranking(top_words([]))uv run top_words.py1. login 3 ###
2. refund 3 ###
3. app 2 ##
4. charged 2 ##
5. slow 2 ##
no words to rankこの設計の理由は次のとおりです。
単語のパターン [a-z]+(?:'[a-z]+)?。 1文字以上の英字と、その後に任意で続くアポストロフィとさらに英字、という形です。can't は1つの単語のまま残りますが、'invalid token' の引用符は単語の一部になりません。(?:...) は、キャプチャしないグループです。普通の (...) を使うと、re.findall は単語全体ではなく、グループの部分だけを返してしまいます。
ストップワードは集合です。 各単語は word not in STOP_WORDS で調べられます。集合なら、リストがどれだけ長くなってもこの確認はほぼ一瞬で終わります。
Counter にジェネレータを直接渡しています。 2つの for と1つの if で1本の流れを作っています。絞り込みを通ったすべてのチケットのすべての単語が、その流れに乗ります。全単語の大きな中間リストは作られません。2つの for は、普通の入れ子ループと同じ順序で書かれています。外側の for が先です。
同点を決めるルールを明示しています。 login と refund はどちらも3、app、charged、slow、twice はどれも2です。5位を決めたのはアルファベット順だったので、twice が落とされました。このルールは完璧ではありません。twice が重要度の低さで落とされたわけではないからです。しかし、ルールは明示されていて、毎回同じです。レポートを読んだ人に理由を聞かれたとき、答えがあります。
空の入力を意図的に扱っています。 空のコレクションに対する max() は ValueError: max() iterable argument is empty を出します。チケットがまったくない静かな週は普通のことで、エラーではありません。そのため print_ranking では、まず順位が空かどうかを確認しています。2行目の出力は、まさにその場合を示しています。
数えることと表示することを分けています。 top_words はリストを返すだけで何も表示しません。print_ranking は表示するだけです。だから、同じ件数をテスト、API、ダッシュボードのどこからでも使えます。{word:<{width}} は、各単語を最も長い単語と同じ幅の中で左揃えにするので、件数の列がきれいに揃います。
ひとつ制限があります。ここでは charged と charge は別々の単語として扱われています。単語の形の違いをまとめるのは、それ自体が別の問題ですし、AI システムは通常、単語を数えるのではなく意味で照合します。その方法はコースの後半で扱います。しかしそこでも、テキストを整え、絞り込み、安定した順序で並べ替えるというこの章の作業が、最初の一歩になります。
うまくいかないとき
KeyError: 'refund' 辞書にまだ存在しないキーに対して counts[word] += 1 と書いています。counts[word] = counts.get(word, 0) + 1 と書くか、collections.Counter を使ってください。
ValueError: too many values to unpack (expected 2) 辞書に対して for key, value in d: と書いています。辞書を直接ループするとキーだけが得られるので、ペアを得るには d.items() と書いてください。
TypeError: unhashable type: 'list' リストを集合に入れたか、辞書のキーとして使いました。集合の要素や辞書のキーは、変わらないものでなければなりません。リストの代わりにタプルを使いましょう:seen.add(("refund", "late"))。
RuntimeError: dictionary changed size during iteration 辞書をループしながら、キーを削除したり追加したりしました。削除する代わりに、新しい辞書を作りましょう:counts = {word: n for word, n in counts.items() if n >= 2}。
AttributeError: 'tuple' object has no attribute 'append' タプルは、作った後で変更できません。値が本当に増えたり減ったりするなら、リストにすべきでした。タプルがレコードなら、新しい値を入れた新しいタプルを作ってください。
ValueError: max() iterable argument is empty 空のコレクションに対して max() や min() を呼んでいます。先に空かどうかを確認するか、既定値を与えてください:max(lengths, default=0)。
ステップ 4 / 6 — 予想する
理解度をチェック
同僚が、件数の辞書から「最も多く出現する単語」を取り出そうとしています。何が表示されるでしょうか?
counts = {"refund": 5, "slow": 1, "login": 3}
print(sorted(counts, reverse=True)[0])- A`refund`
- B`('refund', 5)`
- C`slow`
- D`5`
label(2600) は "critical" を返すはずなのに、エラーは出ずに "slow" を返してしまいます。バグはどこにあるでしょうか?
def label(latency_ms):
if latency_ms >= 1000:
return "slow"
elif latency_ms >= 2000:
return "critical"
return "ok"
print(label(2600))- A条件の順序: `>= 1000` が先に真になって分岐が止まるため、`>= 2000` には到達しない。最も厳しい条件を先に書かなければならない
- B`return "ok"` の前に `else:` が必要である
- C2つ目の条件は `elif` ではなく `if` にしなければならない
- D`2600` は整数なので、`>=` で比較する前に `float()` で変換しなければならない
パイプラインが、100万個の単語それぞれについて、300個のストップワードのどれかに一致するかを調べます。ストップワードはどのコンテナに入れるべきでしょうか?
- A文字列。すべての単語を1本の長いテキストにつなげたもの
- Bタプル。ストップワードは変わらないから
- C集合。`in` の確認は、サイズにかかわらずほぼ一瞬で済むから
- Dリスト。アルファベット順に並べたまま保持できるから
プレイは Pro の機能です
+30 XP が待っています
Pro でこの章をプレイ
読むのは無料で、章はすべて公開されています。無料コースでは、最初の 5 章を無料でプレイできます。第 6 章からは、問題への回答、XP の獲得、連続記録の更新が Pro の機能になります。
Pro でプレイを解放やってみよう
カテゴリごとに上位の単語を求めてみましょう。categories.py というファイルを作り、この章の STOP_WORDS と tokenize をコピーして、次のチケットを追加してください:
TICKETS = [
("billing", "Charged twice for one order."),
("billing", "Refund for the double charge, please."),
("login", "Login fails on the new phone."),
("billing", "Still no refund after the charge."),
("login", "Password reset email never arrives."),
("login", "Login page shows a blank screen."),
]次に、以下を満たすコードを書いてください:
- カテゴリごとに件数を持つ。キーがカテゴリ名、値がそのカテゴリの
Counterである辞書にする(ヒント:dict.setdefaultとCounter.update) - ストップワードと、2文字以下の単語は数えない
- カテゴリごとに上位2語を表示する。件数の多いものが先、同点はアルファベット順
- チケットの順序がどうであっても、カテゴリはアルファベット順に表示する
uv run categories.py は、ちょうど次の出力を表示するはずです:
billing charge=2, refund=2
login login=2, arrives=1そのあと、TICKETS のリストを反転(TICKETS[::-1])して、もう一度実行してみてください。出力は変わるでしょうか? 変わるなら、どの条件が満たされていないのでしょうか? 最後に、同点の決め方を取り除き、件数だけで並べ替えてみてください。login の2語目は何になるでしょうか? そして、順序を逆にしても、それは同じままでしょうか?