言語処理100本ノックをNLP屋がやってみる: 第2章 10～19

# はじめに

[言語処理100本ノック](http://www.cl.ecei.tohoku.ac.jp/nlp100/)の続きをやっていきます。
第1章をやった前回は[こちら](https://qiita.com/moriwo/items/4fde7f725db215ce1a26)。

今回はUNIXコマンドの基礎、です。どちらかというとpythonよりunixコマンド勉強会といった風情の章なので、この記事でもコマンドでの確認を先にやっていきたいと思います。
ひきつづき指摘・ツッコミ等あればぜひお寄せください。
Twitter @watanabemoriwo まで。

# 10. 行数のカウント

> 行数をカウントせよ．確認にはwcコマンドを用いよ．

```sh
$ wc -l hightemp.txt
      24 hightemp.txt
```

24行ですね。pythonでも書いてみましょう。

```python
def count_lines(filename):
    """
    指定ファイルの行数を返します

    >>> count_lines('hightemp.txt')
    24
    """
    with open(filename, 'r') as f:
        return len(list(f))
```

シンプルに。
ただ、このコードだと大きいファイルでも一回全部メモリに読み込んでしまうので、シンプルにこうした方が安全かも。

```python
def count_lines(filename):
    """
    指定ファイルの行数を返します

    >>> count_lines('hightemp.txt')
    24
    """
    count = 0
    with open(filename, 'r') as f:
        for _ in f:
            count += 1
    return count
```

# 11. タブをスペースに置換

> タブ1文字につきスペース1文字に置換せよ．確認にはsedコマンド，trコマンド，もしくはexpandコマンドを用いよ．

まず、比較対象としてもとのファイル（100本ノックのサイトでダウンロードできます）を単純にcatしてみましょう。

```sh:catしただけ
$ cat hightemp.txt 
高知県	江川崎	41	2013-08-12
埼玉県	熊谷	40.9	2007-08-16
...
山形県	鶴岡	39.9	1978-08-03
愛知県	名古屋	39.9	1942-08-02
```

まずは`sed`。sedはStream EDitorの略で、ファイルや標準入力に対してあれやこれや編集を加えられるコマンドです。
ただ、今回のお題だと「タブ」「スペース」をターミナル上で入力するのがちょっと面倒。

```sh:sedの場合
$ sed s/$'\t'/$' '/g hightemp.txt 
高知県 江川崎 41 2013-08-12
埼玉県 熊谷 40.9 2007-08-16
...
山形県 鶴岡 39.9 1978-08-03
愛知県 名古屋 39.9 1942-08-02
```

ちゃんとスペースになってますね。

次に`tr`。こちらは、標準入力の内容を「置換/削除する」のが主眼のコマンドです。

```sh:trの場合
$ tr '\t' ' ' < hightemp.txt 
高知県 江川崎 41 2013-08-12
埼玉県 熊谷 40.9 2007-08-16
...
山形県 鶴岡 39.9 1978-08-03
愛知県 名古屋 39.9 1942-08-02
```

`tr`はコマンドレベルで`¥t`, `¥n` といった表記を受け付けてくれるのでスッキリ書けますね。

最後に、`expand`。こちらはタブ区切りのファイルのタブをいい感じに半角スペースに置き換えて、見た目を揃えてくれるコマンドです。
徐々にコマンドの用途が限定的になってきてます。

```sh:expandの場合
$ expand -t 1 hightemp.txt 
高知県 江川崎 41 2013-08-12
埼玉県 熊谷 40.9 2007-08-16
...
山形県 鶴岡 39.9 1978-08-03
愛知県 名古屋 39.9 1942-08-02
```

ちょうどいいコマンドがあるとスッキリ書けるから、いろいろなツールを把握しておこうね、というメッセージを伝えたい問題なのでしょう。[^1] そして、この後の問題は全体的にツールいろいろご紹介という感じ。

[^1]: でも個人的にはperlのワンライナー派。

で、本題。pythonで書いたら。
doctestが書きづらいので、上記の出力結果とdiffを取ることにします。

```python:nlp11.py
# タブ1文字につきスペース1文字に置換

with open('hightemp.txt', 'r') as f_read:
    with open('nlp11.txt', 'w') as f_write:
        for line in f_read:
            f_write.write(line.replace('\t', ' '))
```

```sh:確認
$ python nlp11.py
$ expand -t 1 hightemp.txt > nlp11expected.txt 
$ diff nlp11.txt nlp11expected.txt # 差分なし
```

# 12. 1列目をcol1.txtに，2列目をcol2.txtに保存

> 各行の1列目だけを抜き出したものをcol1.txtに，2列目だけを抜き出したものをcol2.txtとしてファイルに保存せよ．確認にはcutコマンドを用いよ．

`cut` コマンドは、-f オプションを付けるとタブ区切りのファイルの好きなフィールドだけを取り出すことができます。こんな感じで。

```sh
$ cut -f 1 hightemp.txt > col1.txt
$ cat col1.txt 
高知県
埼玉県
...
山形県
愛知県
```

col2.txtの方も同様にやればよし。
ちなみに、-d オプションで区切り文字を変更することもできます。

```sh:区切り文字を変えてみる。
$ cut -f 1 -d 県 hightemp.txt 
高知
埼玉
...
大阪府	豊中	39.9	1994-08-08
山梨
山形
愛知
```

「県」が含まれない大阪府だけが、区切られずにもとの行全体が出力されてしまいました。

で、pythonで書いてみます。

```python:nlp12.py
# 1列目をcol1.txtに，2列目をcol2.txtに保存


def write_all(filename: str, lines: []):
    """
    指定されたファイルに渡されたイテラブルオブジェクトを出力
    各行、末尾に\nを入れます
    """
    with open(filename, 'w') as f:
        for line in lines:
            f.write(line + '\n')


# 読み込んでタブ区切り。行末の改行は・・・使わないから放置。
with open('hightemp.txt', 'r') as f:
    # リスト内包表記でファイルオブジェクトの各行をタブで区切る。
    data = list(l.split('\t') for l in f)

# リスト内包表記で1列目・2列目だけを取り出し、指定したファイル名に書き込み
write_all('col1.txt', [l[0] for l in data])
write_all('col2.txt', [l[1] for l in data])
```

```sh:確認
$ python nlp12.py
$ cut -f 1 hightemp.txt > col1expected.txt
$ cut -f 2 hightemp.txt > col2expected.txt
$ diff col1.txt col1expected.txt 
$ diff col2.txt col2expected.txt # どちらも差分なし
```

# 13. col1.txtとcol2.txtをマージ

> 12で作ったcol1.txtとcol2.txtを結合し，元のファイルの1列目と2列目をタブ区切りで並べたテキストファイルを作成せよ．確認にはpasteコマンドを用いよ．

`paste` コマンドは、指定した各ファイルを1行ずつ読んで、タブ区切りの1行に変換してくれるコマンドです。

```sh
$ paste col1.txt col2.txt
高知県	江川崎
埼玉県	熊谷
...
山形県	鶴岡
愛知県	名古屋
```

pythonでも書いてみますが、とりあえずここでは2つのファイルが同じ行数であると仮定してしまいます。

```python:nlp13.py
# 12で作ったcol1.txtとcol2.txtを結合し，元のファイルの1列目と2列目をタブ区切りで並べたテキストファイルを作成


with open('col1.txt', 'r') as f:
    # 各行、読み込みながら行末の改行を取り除きます。
    col1 = list(l.strip() for l in f)

with open('col2.txt', 'r') as f:
    col2 = list(l.strip() for l in f)

with open('col1and2.txt', 'w') as f:
    for elem1, elem2 in zip(col1, col2):
        f.write('{}\t{}\n'.format(elem1, elem2))
```

```sh:確認
$ paste col1.txt col2.txt > col1and2expected.txt
$ python nlp13.py 
$ diff col1and2.txt col1and2expected.txt 
```

うん、大丈夫ですね。

# 14. 先頭からN行を出力

> 自然数Nをコマンドライン引数などの手段で受け取り，入力のうち先頭のN行だけを表示せよ．確認にはheadコマンドを用いよ．

ほぼ`head`コマンドのヘルプの文言のようですね。こんな感じで使います。

```sh
$ head -n 5 col1.txt
高知県
埼玉県
岐阜県
山形県
山梨県
```

pythonでシンプルに書くとこんな感じ。
ここでは、標準入力から一行読み取る方法として `input()` を使います。

```python:nlp14.py
# 標準入力の先頭からN行を出力
import sys

n = int(sys.argv[1])

for _ in range(n):
    print(input())
```

pythonでコマンドライン引数を取得したいときは、`sys.argv`を使います。
ちなみに、`sys.argv[0]` には実行したファイル名が入ってますのでご注意を。

```sh:確認
$ python nlp14.py 5 < col1.txt 
高知県
埼玉県
岐阜県
山形県
山梨県
```

大丈夫そうです。
ただし、引数に指定した n が入力行数より多いと・・・

```sh:エラーその1
$ python nlp14.py 500 < col1.txt 
高知県
埼玉県
...
山形県
愛知県
Traceback (most recent call last):
  File "nlp14.py", line 7, in <module>
    print(input())
EOFError: EOF when reading a line
```

もうデータがないよ、と怒られてしまいますね。
あと、引数に指定したものが整数値じゃないと・・・

```sh:エラーその2
$ python nlp14.py < col1.txt
Traceback (most recent call last):
  File "nlp14.py", line 4, in <module>
    n = int(sys.argv[1])
IndexError: list index out of range
$ python nlp14.py abc < col1.txt
Traceback (most recent call last):
  File "nlp14.py", line 4, in <module>
    n = int(sys.argv[1])
ValueError: invalid literal for int() with base 10: 'abc'
$ python nlp14.py 10.4 < col1.txt
Traceback (most recent call last):
  File "nlp14.py", line 4, in <module>
    n = int(sys.argv[1])
ValueError: invalid literal for int() with base 10: '10.4'
```

こちらもエラーになってしまいます。
丁寧に try~except を書いておきましょう。

```python:nlp14.py
# 標準入力の先頭からN行を出力
import sys

try:
    n = int(sys.argv[1])
except IndexError:
    print('表示する行数を指定してください')
    print('usage: python nlp14.py [表示する行数] < 入力ファイル')
    exit(1)
except ValueError:
    print('表示する行数は整数で指定してください')
    print('usage: python nlp14.py [表示する行数] < 入力ファイル')
    exit(1)

for _ in range(n):
    try:
        print(input())
    except EOFError:
        # 指定行数より入力が少なければ終了してしまう
        break
```

これで、エラーが発生しなくなります。

```sh:エラーハンドリング後
$ python nlp14.py < col1.txt
表示する行数を指定してください
usage: python nlp14.py [表示する行数] < 入力ファイル
$ python nlp14.py abc < col1.txt
表示する行数は整数で指定してください
usage: python nlp14.py [表示する行数] < 入力ファイル
$ python nlp14.py 10.4 < col1.txt
表示する行数は整数で指定してください
usage: python nlp14.py [表示する行数] < 入力ファイル
$ python nlp14.py 500 < col1.txt
高知県
埼玉県
...
山形県
愛知県
```

・・・ずっとこのノリをやるとくどいので、この先はエラーハンドリングはほどほどで行きます。

# 15. 末尾のN行を出力

> 自然数Nをコマンドライン引数などの手段で受け取り，入力のうち末尾のN行だけを表示せよ．確認にはtailコマンドを用いよ．

こちらもほぼ`tail`コマンドのヘルプの文言。

```sh
$ tail -n 5 col2.txt 
鳩山
豊中
大月
鶴岡
名古屋
```

pythonでも実装してみましょう。
まず、実はさっきの 14 でも同じだったんですが、手っ取り早いのは一旦リストにファイル全体を突っ込んでしまうやり方。
全行読むなら、`sys.stdin` をリスト化してしまうのが楽ちん。

```python:nlp15.py
# 標準入力の末尾からN行を出力
import sys


n = int(sys.argv[1])
lines = list(sys.stdin)

for line in lines[-n:]:
    print(line, end='')
```

`input()` と違って、`sys.stdin` から読み込む時は行末の改行がコードが取り除かれません。
なので表示時に `end=''` を指定して、二重改行しないようにしています。
これで実行してみると、最後の5行を出力することが出来ます。

```sh:確認
$ python nlp15.py 5 < col2.txt
鳩山
豊中
大月
鶴岡
名古屋
```

ただ、これだとまた「でかいファイルだとメモリが足りない問題」が起こる場合も。
そういう時は、直近のn行だけをメモリに保持しておくなんていう工夫もありかもしれません。

```python:nlp15.py
# 標準入力の末尾からN行を出力
import sys


n = int(sys.argv[1])
lines = []

for line in sys.stdin:
    lines.append(line)
    # n行以上保持していたら保持している一番上の行を捨てる
    if len(lines) > n:
        lines.pop(0)

for line in lines:
    print(line, end='')
```

ただ、リストの1件目を `pop` するのは実は処理としては非効率です。配列の1件目を消すと残りを全部1件ずつ前にずらさなければならないからですね。
上記の方式でも[キューを使うなどして速くする方法](https://x1.inkenkun.com/archives/944)もありますが、本気でファイルがでかいなら分割して扱うなり何なり、別の工夫したほうが幸せになれそうな予感。

# 16. ファイルをN分割する

> 自然数Nをコマンドライン引数などの手段で受け取り，入力のファイルを行単位でN分割せよ．同様の処理をsplitコマンドで実現せよ．

というわけでファイルの分割です。
`split`コマンドが出力するファイルのファイル名は、コマンドラインの最後の引数に指定したファイル名の後に「aa」〜「zz」までの 26*26 = 676ファイルに分けることが出来ます。
-l オプションで何行ごとに分割するかを指定します。

* なお、--numeric-suffixes オプションを付けて、00〜99 が付くようにしたり、-n オプションで分割数を指定したりすることができる実装もありますが、MacOS の split はできなかった・・・。

```sh
$ split -l 15 col1.txt col1_split.
$ ls col1_split.* # .aaと.abの2ファイルが出力されます
col1_split.aa	col1_split.ab
$ cat col1_split.aa # .aaを見てみる。オプションの通り15行出力。
高知県
埼玉県
...
静岡県
愛媛県
$ cat col1_split.ab # .abを見てみる。残りの行が入っている。
山形県
岐阜県
群馬県
千葉県
埼玉県
大阪府
山梨県
山形県
愛知県
```

N個のファイルに分割したいのであれば、(-nオプションがないとすれば) `wc -l` の出力を `awk` でパースして行数を取り出し、 `expr` コマンドで分割したいファイル数分に割って、`split -l` に指定する感じでしょうか。

```sh:無理やりコマンドだけで
$ split -l $(expr $(wc -l col1.txt | awk '{print $1}') / 5) col1.txt col1_split.
```

これは5ファイルに分割したい場合。
ただ、上記のやり方だと 100行のファイルは20行ずつ5ファイルに分割されますが、101行のファイルは101 / 5 = 20 (`expr`の `/` は商の部分を取り出します)なので 20行のファイル5つと1行のファイル1つの計6つに分割されてしまいます。
めんどくさいので[^2]、pythonに頼ってみましょう。

[^2]: うまいやり方があるのであればぜひ教えてください。。。

```python:nlp16.py
# 標準入力から読み込んだファイルをN分割
# 出力ファイル名は nlp16.00, nlp16.01 ...
import math
import sys

n = int(sys.argv[1])

lines = list(sys.stdin)
size = math.ceil(len(lines) / n)  # math.ceil は小数点以下を切り上げてくれます

for i in range(n):
    filename = 'nlp16.{0:02}'.format(i)
    with open(filename, 'w') as f:
        for l in lines[i * size: (i + 1) * size]:
            f.write(l)
```

メモリがガッツリ使えるのであれば、一度`lines`に全行読み込んでしまえば、スライスを使って簡単にデータは分割できますね。
実行してみます。

```sh:確認
$ python nlp16.py 3 < col1.txt  # 3つに分割
$ ls nlp16.*  # 3ファイルできてる
nlp16.00        nlp16.01        nlp16.02        nlp16.py
$ cat nlp16.02  # 8行入っている。元ファイルが24行なので正しい。他の2ファイルも8行ずつ。
岐阜県
群馬県
千葉県
埼玉県
大阪府
山梨県
山形県
愛知県
$ python nlp16.py 5 < col1.txt  # 今度は5つに分割
$ ls nlp16.*  # ファイルは00~04の5つ出来ている。
nlp16.00        nlp16.01        nlp16.02        nlp16.03        nlp16.04        nlp16.py
$ cat nlp16.04  # 最後のファイルは4行。ほかは5行ずつになっています。
大阪府
山梨県
山形県
愛知県
```

いいかんじ。

# 17. １列目の文字列の異なり

> 1列目の文字列の種類（異なる文字列の集合）を求めよ．確認にはsort, uniqコマンドを用いよ．

`sort`, `uniq`を組み合わせれば簡単に重複を許さない一覧が作れます。
`uniq` は入力に同じ内容の行が連続した場合に1行しか出力しないというコマンドで、ソートされたテキストを入力にすれば「重複を取り除く」ことができるのです。

```sh
$ cat col1.txt | sort | uniq
愛媛県
愛知県
大阪府
千葉県
静岡県
山形県
山梨県
和歌山県
岐阜県
群馬県
高知県
埼玉県
```

pythonでも書きます。`set` 型を使うことで、重複を許さないコレクションに変換することが出来ます。

```python:nlp17.py
# １列目の文字列の異なり
import sys

for elem in set(sys.stdin):
    print(elem, end='')
```

実行してみると。

```sh:確認
$ python nlp17.py < col1.txt | sort
愛媛県
愛知県
大阪府
千葉県
静岡県
山形県
山梨県
和歌山県
岐阜県
群馬県
高知県
埼玉県
```

同じ結果になってますね。
念の為両方ソートしてdiffをとって確認。

```sh:確認
$ cat col1.txt | sort | uniq | sort > nlp17expected.txt 
$ python nlp17.py < col1.txt | sort > nlp17actual.txt
$ diff nlp17actual.txt nlp17expected.txt  # 差分なし
```

※寝ぼけてお題を読み間違えてたので、修正しました。

# 18. 各行を3コラム目の数値の降順にソート

> 各行を3コラム目の数値の逆順で整列せよ（注意: 各行の内容は変更せずに並び替えよ）．確認にはsortコマンドを用いよ（この問題はコマンドで実行した時の結果と合わなくてもよい）．

まずは `sort`だけで。

```sh
$ sort -k 3 -r < hightemp.txt 
高知県  江川崎  41      2013-08-12
岐阜県  多治見  40.9    2007-08-16
...
山形県  鶴岡    39.9    1978-08-03
愛知県  名古屋  39.9    1942-08-02
```

-k オプションで何カラム目をキーにするかを指定し、-r で逆順でのソートを指定しています。
python でも書いてみましょう。

```python:nlp18.py
# nカラム目の逆順でソート
import sys


def key_selector(s: str):
    """
    タブ区切りの3列目を返します
    """
    return s.split('\t')[2]


for line in sorted(sys.stdin, key=key_selector, reverse=True):
    print(line, end='')
```

`sorted` はリスト（的なもの）をソートしながら列挙してくれるメソッドです。
keyに指定しているのはソート時のキーを取り出すメソッド。ここでは`key_selector`というメソッドを作って、その中で渡された文字列をタブ区切りにして3列目を取り出しています。
`reverse=True` を指定することで、ソート順が逆順に。

keyにはラムダ関数を渡すことも出来ます。

```python:nlp18.py
# nカラム目の逆順でソート
import sys

for line in sorted(sys.stdin, key=lambda l: l.split('\t')[2], reverse=True):
    print(line, end='')
```

結果が一致するか確認してみましょう。

```sh:確認
$ sort -k 3 -r hightemp.txt  > nlp18expected.txt
$ python nlp18.py < hightemp.txt > nlp18.txt
$ diff nlp18.txt nlp18expected.txt 
2d1
< 埼玉県        熊谷    40.9    2007-08-16
3a3
> 埼玉県        熊谷    40.9    2007-08-16
```

ん？ と思ったら、以下の2行が最高気温同率2位だからですね。

```
埼玉県	熊谷	40.9	2007-08-16
岐阜県	多治見	40.9	2007-08-16
```

お題でも「結果が必ずしも合わなくてもいい」と書かれているのは、ここがあるからなのでしょう。

※ちなみに、何も考えずに文字列として温度をソートしてますが、場合によっては文字列でソートすると期待したソート順にならない場合もあると思うので、ご注意を。マイナスがあるときとか。

# 19. 各行の1コラム目の文字列の出現頻度を求め，出現頻度の高い順に並べる

> 各行の1列目の文字列の出現頻度を求め，その高い順に並べて表示せよ．確認にはcut, uniq, sortコマンドを用いよ．

`uniq` コマンドは -c オプションを付けると重複した件数をカウントしてくれます。これと、ここまでの内容を使えば簡単。

```sh
$ sort < col1.txt | uniq -c | sort -k 1 -r
   3 群馬県
   3 山梨県
   3 山形県
   3 埼玉県
   2 静岡県
   2 愛知県
   2 岐阜県
   2 千葉県
   1 高知県
   1 愛媛県
   1 大阪府
   1 和歌山県
```

pythonでも書いてみましょう。今度は、辞書型(`dict`)を使います。

```python:nlp19.py
# 1コラム目の出現頻度
import sys


data = {}

for line in sys.stdin:
    key = line.split('\t')[0]
    data[key] = data.get(key, 0) + 1

for key, value in sorted(data.items(), key=lambda kv: kv[1], reverse=True):
    print('{}\t{}'.format(value, key))
```

`data.get(key, 0)` は、辞書`data`に指定したキーがなければ、デフォルト値として0を返すという意味です。
これによって、初出のキー値でも KeyError が出ないようにできます。
実行してみましょう。

```sh:確認
$ python nlp19.py < hightemp.txt 
3       埼玉県
3       山形県
3       山梨県
3       群馬県
2       岐阜県
2       静岡県
2       愛知県
2       千葉県
1       高知県
1       和歌山県
1       愛媛県
1       大阪府
```

また同じ値がいるので完全に上のコマンドと結果が一致はしませんが、まあ大丈夫でしょう。

## 脱線: 本当は python より C# が好き

ちなみに、上の処理は `itertools.groupby` を使って書くことも出来ますが・・・

```python:nlp19.py
# 1コラム目の出現頻度
import sys
from itertools import groupby

for t in sorted([(len(list(group)), key) for key, group
                 in groupby(sorted(sys.stdin),
                            key=lambda l: l.split('\t')[0]
                            )],
                key=lambda t: t[0], reverse=True):
    print('{}\t{}'.format(t[0], t[1]))
```

あまり綺麗なコードになりません。（もっと綺麗に書けるぜ！って人募集）
ほぼ同じことをやってるんですが、C# だと LINQ でこんなに綺麗に。

```C#:Nlp19.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;

public class Nlp19
{
    public static void Main()
    {
        foreach(var group in File.ReadLines("hightemp.txt")
            .GroupBy(line => line.Split("\t".ToArray())[0])
            .OrderByDescending(group => group.Count()))
        {
            Console.WriteLine("{0}\t{1}", group.Count(), group.Key);
        }
    }
}
```

python のリスト内包表記と itertools 系の実装って、思考の順番に対して記述の順番が行ったり来たりするんですよね。それに対して C# (とかRubyとか) はメソッドチェインで思考と同じ順番の記述がしやすい。

ほぼ同じ内容にコメントを書いてみると分かりやすさの差が一目瞭然。

```C#:C#の場合
File.ReadLines("hightemp.txt") // ファイルを読み込んで
    .GroupBy(line => line.Split("\t".ToArray())[0]) // グルーピングする。キーは各行のタブ区切りの1件目。
    .OrderByDescending(group => group.Count()) // 降順にソート。キーはグループの件数。
```

```python:pythonの場合
# ソートします
sorted([
    # ソートするのは「groupの件数」と「key」のタプルをで、
    (len(list(group)), key)
    # その key, group はグルーピングした結果で
    for key, group in groupby(
        # 何をグルーピングしたかというと sys.stdin の内容をソートしたやつで
        sorted(sys.stdin),
        # グルーピングのキーは各行のタブ区切りの1件目で、
        key=lambda l: l.split('\t')[0]
    )],
    # ・・・で最初に言ったソートするときのキーはソート対象のタプルの1個めの要素で、逆順にソート。
    key=lambda t: t[0], reverse=True)
```

なので、scikit-learn とか numpy とか使わなくて済むレベルのデータマイニングとかしたい人は、正直 C# 使ったほうが幸せになれると思いますよ。[^3]

[^3]: ちなみに python の方でわざわざリスト内包表記で一旦 `len(list(group))` としているのは、python の　`itertools.groupby` の結果は純粋なイテレータだからです。つまり、一度しか評価できない。C# の `IEnumerable<T>.GroupBy` で帰ってくる `IGrouping<TKey, TElement>` はそれ自体はイテレータではないので、何度でも評価できるのが大きなアドバンテージなのだと思います。

といいつつ次回も python で書いていきます。それではまた。