Jupyter Notebook でボタンを使う

以前、Jupyterでインタラクティブに関数を実行する方法を紹介しました。
参考: Jupyter Notebookでインタラクティブに関数を実行する

この時は、Jupyter Widgets のinteract というのを使って、スライドバー等を動かしたら自動的に値を変更してグラフを描く関数を実行する例を取り上げました。

今回は、値を変えるとかではなく、単純にNotebook上にボタンを表示して、そのボタンを押した瞬間に何か処理を実行するような方法をまとめます。

例えば、多くのテキストの値が格納されたDataFrameを対象に、ボタンをクリックするごとに次のテキストが読めるとか、何かしらの一連の処理を都度中断して経過を観察しながら徐々に実行していくとかそういった用途を想定してます。

早速説明に入りましょう。まずボタンの作成です。
前回の時は、 ipywidgets.interact 一つでいろんなUIが作れたのですが、ボタンはその中に含まれていません。ボタンを作りたい時は、ipywidgets.widgets.Buttonを使います。
さらに、ボタンを作っただけだと表示されないので、display します。もしくは、セルの最後の行で作ったボタンインスタンスを呼び出しても表示されます。(ただ、この方法は最後の1個しか出せないので、通常はdisplayしましょう。)

import ipywidgets as widgets
from IPython.display import display


button = widgets.Button(description="ボタンです")
display(button)

もしくは、下記の通り。

button = widgets.Button(description="ボタンです")
button

これで、「ボタンです」と表示されたボタンが作成されます。
description 以外の引数はドキュメントを参照してください。スタイルを警告に変えたり等の設定ができます。
参考: Widget List — Jupyter Widgets 8.0.0rc0 documentation

さて、ボタンを作成してもまだこのボタンには何の機能も実装されていません。ドキュメントにある通り、実行したい関数(仮にfooとする)を用意して、button.on_click(foo) と登録する必要があります。ボタンを押したら出力先にボタンが押された回数を表示するメッセージを出すようにしてみましょう。

button = widgets.Button(description="ボタンです")
output = widgets.Output()  # 出力先


i = 0  # ボタンが押された回数を記録しておく
def on_button_clicked(b):
    global i
    i += 1
    output.clear_output(True)  # 前のクリック時の出力を消す
    with output:
        print(f"{i}回ボタンを押しました。")


button.on_click(on_button_clicked)  # ボタンが押されたときに実行するメソッドをセット
display(button, output)

上記のコードで、ボタンが表示され、ボタンをクックするたびに
「i回ボタンを押しました。」の文字列が更新されていきます。
outputの使い方が独特ですね。 with output: するとそのスコープ配下のprint文の出力がoutputに表示されます。詳しくはOutputに関するドキュメントをご参照ください。
参考: Output widgets: leveraging Jupyter’s display system — Jupyter Widgets 8.0.0rc0 documentation

clear_output に True を渡していますが、このTrueは次の描写対象を待って消す、という処理になります。デフォルトはFalseです。
以前の記事で紹介した、セル出力のクリアと同じですね。
参考: jupyter notebookのセルの出力をコードでクリアする

ボタンの出力をoutputに出す方法は、次のように@を使ってデコレーターとして書く方法もあります。

button = widgets.Button(description="ボタンです")
output = widgets.Output()  # 出力先


i = 0  # ボタンが押された回数を記録しておく
@output.capture()
def on_button_clicked(b):
    global i
    i += 1
    output.clear_output(True)  # 前のクリック時の出力を消す
    print(f"{i}回ボタンを押しました。")


button.on_click(on_button_clicked)  # ボタンが押されたときに実行するメソッドを記録
display(button, output)

正直どちらも慣れるまではわかりにくいです。

実は昔のバージョンのjupyterはOutputを使わないとボタンの処理でprintした文を画面に出してくれなかったという話を聞いたのですが、最近のjupyterはOutputを作ってなくてもちゃんと出力してくれます。(どのバージョンからそうなったのかは調べられていないです。)
ただ、OutputはOutputとして作っておかないと、セルの出力にボタンもボタンの処理の出力も混ぜて出してしまうと、クリアしたときにボタンも消えるという不便さがあるので、Outputは必須でなくても使った方が良いです。

一度表示したボタンを消したい場合(要は、2回押すことがないとか表示するボタンの種類が次々変わるような作業をするとかといった場合)は、button.close()でボタンを消すことができます。ドキュメントのButtonのところをどう読んでもこれについての記載がなかったので僕は結構戸惑いました。buttonに実装されているメソッド/プロパティを一通り眺めて見つけたのですが、実はドキュメントでは次のページに記載されていたようです。
参考: Simple Widget Introduction — Jupyter Widgets 8.0.0rc0 documentation

以下のコードで押すと消えるボタンが

button = widgets.Button(description="押すと消えるボタン")


def on_button_clicked(b):
    b.close()


button.on_click(on_button_clicked)
display(button)

消えなくてもいいけど押せないようにしてほしい、という場合は、button.disabled の値(通常False) にTrueを代入しましょう。(ただの代入なのでコード例省略)

さて、ボタンを複数表示して押されたボタンによって処理を変えたい、という場面は多々あると思います。ボタンの数だけ on_click に設定するメソッドを定義してそれぞれ設定しても一応動くのですがコードがカッコ悪いので嫌ですね。

Buttonに何か値を持たせて、それに応じて処理を変える、というのを考えたのですが、Button()の引数に valueに相当するものがなさそうです。さっきのページに全てのウィジェットはvalueプロパティを持っている、的なことが書いてあるのに、ボタンにはvalueがないんですよね。(AttributeError: ‘Button’ object has no attribute ‘value’ が起きます。)
引用: All of the IPython widgets share a similar naming scheme. To read the value of a widget, you can query its value property.

一応、案の一つとして、button.description でボタンに表示しているテキストを取得できます。これの値によって処理を振り分けることは可能です。

ただ、実際これを使うと不便です。画面には男性/女性と表示しつつプログラム内の値としては1/2を使いたい、みたいな場面は多々あり、毎回マッピングしないといけません。

もう一つ、buttonはvalueプロパティを持っていませんでしたが、試し見てたところ後から設定することは可能でした。これを使うと一つの関数で挙動を振り分けることもできそうです。ボタンを3個作ってどれが押されたかわかるようにしてみましょう。
(ただ押したボタン名を表示するだけだったら無理やりvalue使わなくても、descriptionでいいじゃないか、って感じの例ですみません。)

# ボタンのインスタンスを作る
button_0 = widgets.Button(description="ボタン0")
button_1 = widgets.Button(description="ボタン1")
button_2 = widgets.Button(description="ボタン2")
# valueプロパティに値を設定する
button_0.value = 0
button_1.value = 1
button_2.value = 2
output = widgets.Output()  # 出力先


def on_button_clicked(b):
    output.clear_output(True)  # 前のクリック時の出力を消す
    with output:
        print(f"{b.value}番のボタンが押されました")


button_0.on_click(on_button_clicked)
button_1.on_click(on_button_clicked)
button_2.on_click(on_button_clicked)
hbox = widgets.HBox([button_0, button_1, button_2])  # HBoxを使うと横に並べることができる
display(hbox, output)

以上のコードで、ボタンが3個表示され、押したボタンによって異なる結果がアウトプット出力されます。

単純にボタンを並べると縦に並んでしまうので、HBoxというのを使って横向きに並べました。このようなレイアウト関連のツールはこちらのページにまとまっています。
参考: Layout and Styling of Jupyter widgets — Jupyter Widgets 8.0.0rc0 documentation

想定外に記事が長くなってきたので一旦今回の更新はここまでにします。
(OutputとかHBoxとかButton以外の紹介も必要でしたし。)

本当はこのButtonを使ったアノテーションのコードとか紹介したかったので次の記事でそれを書こうと思います。

Pandas.DataFrameの表示設定を変更する

以前調べたのですがしばらく使わないうちにど忘れしてしまっていたので、改めてPandasのDataFrameの表示設定を変更する方法についてまとめていきます。

ちなみにドキュメントはこちらです。
参考: Options and settings — pandas 1.4.1 documentation

(ターミナルで動かしている時も実は事情は同じなのですが、)特にJupyter NotebookでPandasを使ってデータ分析をしている時など、頻繁にデータフレームの中身を表示して中身を確認します。その場合、デフォルトの表示設定では不便な思いをすることがよくあるので、設定の変更方法を知っておくと役に立ちます。

一番頻繁に使うのは表示行数の上限設定でしょうか。
Pandasのデフォルトでは、60行をこえるDataFrameを表示する時、中間のデータが省略されて先頭と末尾のそれぞれ数行だけが表示されます。
例えば以下のようにです。

import pandas as pd


df = pd.DataFrame(range(61), columns=["value"])
print(df)
"""
    value
0       0
1       1
2       2
3       3
4       4
..    ...
56     56
57     57
58     58
59     59
60     60

[61 rows x 1 columns]
"""

中身をもっとみたい時は、head(60), tail(60), sample(60)やその他ilocなどのスライスを使って対象行数を60行以内に抑えるか、for文で回して中身をprintするなどの対応をとることが多いですが、実はこの60行の上限はPandasが内部の値として持っているもので、これ子を書き換えることが可能です。

# pd.options.display.max_rows が表示される上限の行数
print(pd.options.display.max_rows)
# 60

# 値を代入すると設定が変わる
pd.options.display.max_rows = 100

0~61の連番を表示してみてもこの記事のスペースを圧迫するだけなので設定変更した結果表示できるようになったDataFrameの例は載せませんが、上記のコードの最後の行のようにして、max_rowsに大きめの値を入れると、もっと多くの行数を表示できるようになります。
Noneにすると上限がなくなりますが、巨大なDataFrameを表示しようとしてしまった場合にブラウザが固まることがあるなどデメリットもあるので気をつけてください。

設定の確認と値の変更は、上記のようにpd.options.display以下のプロパティを見ていくほか、get_option/ set_option というメソッドで取得/設定することも可能です。これは、この後見ていく他のオプションでも同様です。

# 先ほど設定した 100 表示される
print(pd.get_option("display.max_rows"))
# 100

# set_option で値を設定できる
pd.set_option("display.max_rows", 120)

さて、表示量の上限は行数だけでなく、列数や、1行内の文字数にも設定されています。
それぞれ、20列と80文字が上限です。そのため、それを超えるDataFrameをprintすると、列が省略されたり改行されたりします。

# 横に表示される文字数の上限は80文字
print(pd.options.display.width)
# 80

# 同時に表示される列数は20列まで
print(pd.options.display.max_columns)
# 20

# 21列あるテーブルを表示すると、 途中の列が ... で省略される
# さらに、80文字を超えたので改行された
print(pd.DataFrame([range(21)]*5))

"""
   0   1   2   3   4   5   6   7   8   9   ...  11  12  13  14  15  16  17  \
0   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
1   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
2   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
3   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
4   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   

   18  19  20  
0  18  19  20  
1  18  19  20  
2  18  19  20  
3  18  19  20  
4  18  19  20  

[5 rows x 21 columns]
"""

これも、設定を変更すると改善します。例えば、40列、160文字までOKにすると次のようになります。

pd.options.display.width = 160
pd.options.display.max_columns = 40

print(pd.DataFrame([range(21)]*5))
"""
   0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20
0   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
1   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
2   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
3   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
4   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
"""

先ほど設定を変更した文字数の上限は、行トータルの文字数です。
1つのセル内の文字数には、max_colwidth というまた別の設定があります。

# デフォルトは50文字
print(pd.options.display.max_colwidth)
# 50

# 51文字以上のテキストは ... で省略される
print(pd.DataFrame(["a"*51]))
"""
                                                   0
0  aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
"""

# 100文字に増やしてみる
pd.options.display.max_colwidth = 100
# 省略されずに表示される
print(pd.DataFrame(["a"*51]))
"""
                                                     0
0  aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"""

文字数についての表示のほか、数値については小数を何桁で四捨五入して表示するかといったことも設定可能です。実はこの設定についてはこのブログでも何度か使ったことがあります。
そのうち1記事がこちらです。
参考: 文書をTfidfVectorizerでベクトル化したときの正規化について

この記事では、 pd.options.display.precision = 3 として、小数点以下3位までにヒョ時を制限しています。

import numpy as np


# 乱数でデータ生成
df = pd.DataFrame(np.random.random(size=(3, 3)))
# デフォルトは6桁
print(pd.options.display.precision)
# 6

print(df)
"""
          0         1         2
0  0.222825  0.134517  0.054559
1  0.286993  0.400856  0.117309
2  0.960378  0.022352  0.855942
"""

# 3桁までに設定すると小数点以下3桁しか表示されない
pd.options.display.precision = 3
print(df)
"""
       0      1      2
0  0.223  0.135  0.055
1  0.287  0.401  0.117
2  0.960  0.022  0.856
"""

細かい話なのですが、この時の丸め方は通常の四捨五入ではないのでご注意ください。ほとんどの値については四捨五入と同じ挙動になるのですが、丸められる桁の値がピッタリ5の場合、その上の桁が偶数の方に丸められることがあります。言葉で書くより、実例を見ていただいた方がわかりやすいと思うのでやってみます。以下の例は明らかに不自然な挙動をしているのが伝わると思います。

pd.options.display.precision = 3
print(pd.DataFrame([1.0015, 1.0025, 1.0035, 1.0045]))
"""
       0
0  1.002
1  1.002
2  1.004
3  1.004
"""

# 桁数によっては、偶数への丸めにならないこともある。
pd.options.display.precision = 4
print(pd.DataFrame([1.00015, 1.00025, 1.00035, 1.00045]))
"""
        0
0  1.0002
1  1.0003
2  1.0004
3  1.0005
"""

通常の四捨五入をしてほしい、という場合は、displayの設定変更ではなく専用のメソッドを使って四捨五入しましょう。

さて、ここまで自分がよく使うやつを中心に挙げてきましたが、他にも設定可能な項目はたくさんあります。公式ドキュメントにまとまっているので、一度目を通されることをお勧めします。
再掲: Options and settings — pandas 1.4.1 documentation

MeCabの半角スペース、全角スペース、タブ、改行に対する挙動について

某所でMeCabは半角スペースを無視するというコメントを見かけ、ちょっと疑問に思ったので調べました。そのついでに、スペースと似たような文字(全角スペースやタブ、改行など)についても調査しています。
ちなみに、Pythonラッパーの mecab-python3==1.0.4 で動作確認していますがコマンドラインの生MeCabでも挙動は同様です。

まず前提として、単語(形態素)の途中に半角スペースが入った場合、MeCabはその半角スペースの位置で単語を区切ります。こういう意味ではMeCabは半角スペースを無視しないと言えます。

print(tagger.parse("メロスは激怒した"))
"""
メロス	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 激怒 の激と怒の間に半角スペースを挟むと単語が割れる
print(tagger.parse("メロスは激 怒した"))
"""
メロス	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
激	名詞,サ変接続,*,*,*,*,激,ゲキ,ゲキ
怒	動詞,自立,*,*,五段・ラ行,体言接続特殊2,怒る,イカ,イカ
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS*,特殊・タ,基本形,た,タ,タ
EOS
"""

これだけ見ると、MeCabは半角スペースを無視しないじゃないか、という話なのですが、実はそれは僕の早とちりで、MeCabが半角スペースを無視するケースはありました。それは単語と単語の間に半角スペースがあった場合です。

今度は「激怒」の前に半角スペースを置いてみます。そして事業をもっと正確に見るために、単語の生起コストと連接コストを出力するようにします。
参考: MeCabの出力形式を変更する
%cが生起コストで、%pcが連接コストです。

# コストを表示する設定でtaggerを生成
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic" +
                      r" -F %m\\t%c\\t%pC\\t%H\\n"
                     )

print(tagger.parse("メロスは激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 激怒の前(はの後ろ)に半角スペースを挟む
print(tagger.parse("メロスは 激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

結果が全く同じになりましたね。注目すべきは、はと激怒の間の連接コスト、238です。
前の単語との連接コストが計算されているのですが、空白との連接コストではなくその前の「助詞,系助詞のは」との連接コストが使われています。
この例では、半角スペースは無視された、と言えるでしょう。

ちなみに、タブ、改行(\n)は半角スペース同様に無視されます。一方で全角スペース、改行(\r\n)は無視されません。(正確には\r\nの\n部分は無視されますが、\rが記号,一般として残ります。)

# タブは無視される
print(tagger.parse("メロスは\t激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 改行(\n)も無視される
print(tagger.parse("メロスは\n激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒	4467	238	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 全角スペースは無視されない
print(tagger.parse("メロスは 激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
 	1287	-355	記号,空白,*,*,*,*, , , 
激怒	4467	341	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

# 改行(\r\n)も無視されない。printすると見えませんが表層形に\r だけ残ります。\nは消えて。
print(tagger.parse("メロスは\r\n激怒した"))
"""
メロス	9461	-283	名詞,一般,*,*,*,*,*
は	3865	-3845	助詞,係助詞,*,*,*,*,は,ハ,ワ
	4769	-71	記号,一般,*,*,*,*,*
激怒	4467	-272	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	5500	-7956	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

4パターンとも激怒の前に各記号を入れましたが、無視されるものと考慮されるものに分かれました。全角スペースや\rについては形態素の一つとして扱われ、その次の単語である「激怒」の前の単語との連接コストの計算にも反映されています。

テキストデータを前処理するときに、表記揺れの対応として全角スペースを半角スペースに置き換えたり、改行コードを\r\nを\nに揃えたりといったことをよくやっていたのですが、この操作はその後の形態素解析の結果に影響を与えてしまっていたのですね。
大体その後、改行を空白スペースに置換したりするのですが、これは影響なさそうです。

この改行を無視して、その前の単語との間の連接コストが形態素解析の結果に反映されるというのは僕にとっては非常に驚きでした。(BOSを挿入してるわけではないので、言われてみればそうかという気もしますが。)

例えば、次の2つのテキスト中の「中国語を勉強します。」の形態素分析結果が違う、ということが予想できた人ってあまりいなのではないでしょうか。

text1 = """来月から留学します。
中国語を勉強します。"""
text2= """来月から留学します
中国語を勉強します"""

# 文末に。があるテキストの場合
print(tagger.parse(text1))
"""
来月	5123	-316	名詞,副詞可能,*,*,*,*,来月,ライゲツ,ライゲツ
から	4159	-4367	助詞,格助詞,一般,*,*,*,から,カラ,カラ
留学	5355	2	名詞,サ変接続,*,*,*,*,留学,リュウガク,リューガク
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。	215	-3050	記号,句点,*,*,*,*,。,。,。
中国語	5383	-952	名詞,一般,*,*,*,*,中国語,チュウゴクゴ,チューゴクゴ
を	4183	-4993	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
勉強	4452	-1142	名詞,サ変接続,*,*,*,*,勉強,ベンキョウ,ベンキョー
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。	215	-3050	記号,句点,*,*,*,*,。,。,。
EOS
"""

# 句読点が省略されたテキストの場合
print(tagger.parse(text2))
"""
来月	5123	-316	名詞,副詞可能,*,*,*,*,来月,ライゲツ,ライゲツ
から	4159	-4367	助詞,格助詞,一般,*,*,*,から,カラ,カラ
留学	5355	2	名詞,サ変接続,*,*,*,*,留学,リュウガク,リューガク
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
中国	4757	825	名詞,固有名詞,地域,国,*,*,中国,チュウゴク,チューゴク
語	7810	-7313	名詞,接尾,一般,*,*,*,語,ゴ,ゴ
を	4183	-4541	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
勉強	4452	-1142	名詞,サ変接続,*,*,*,*,勉強,ベンキョウ,ベンキョー
し	8718	-5350	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
ます	5537	-9478	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
"""

text1の方は、「中国語」という単語が登場しましたが、text2の方は、「中国」と「語」という2つの単語に割れましたね。
これは、改行を無視して、その前の単語である「助動詞のます」との連接が考慮された結果になります。「記号,句点の。」と「中国語」の連接コストは小さいですが、「ます」と「中国語」の連接コストは大きい(1436)ので「中国語」という形態素が採用されなかったのです。(中国と語の連接コストが非常に小さく、単語が一つ増えるデメリットがあまりなかったのも要因)

以上をまとめると、以下のようになるでしょうか。
– 全角スペースや\rは他の文字と同じように形態素(単語)として扱われる。
– 半角スペース、タブ、改行(\n)は区切り位置として使われその位置で必ず形態素は切られる。
– 半角スペース、タブ、改行(\n)はそれ自体は形態素としては扱わず結果にも表示されない。
– 半角スペース、タブ、改行(\n)は連接コストの計算時は無視される。

半角スペースも形態素結果に表示してほしいよ、という場合は、表示形式のオプションで、%Mを使うことで表示できます。半角スペースの次の単語の表層系に存在したスペースをくっつけて表示してくれるようです。ただ、正直これを使う場面がすぐには思いつきません。

# %m の代わりに %M を使うと半角スペースも表示される
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic" +
                      r" -F %M\\t%H\\n"
                      )

print(tagger.parse("メロスは 激怒した"))
"""
メロス	名詞,一般,*,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
 激怒	名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS
"""

激怒、の前にスペースが入って半角1文字分字下げされているのがわかりますね。
読みはゲキドだけなので、原型、読み、発音では無視されたままであることもわかります。

NumPyの2次元配列(行列)から三角行列を取り出す

今回もNumPyの小ネタです。特に難しい処理ではないのですが、NumPyの行列(2次元配列)から三角行列を取り出したいことがあります。
自分の場合は、距離行列とか相関行列などに対して、$x_i$と$x_j$の距離や相関と、$x_j$と$x_i$の距離や相関は等しいから一方だけでいいとかそういう状況が多いです。

まぁ、値を取り出すだけなら、2重のforループなどをちゃんと書けば済む話なのですが、前回の記事で紹介したようなちょっとしたテクニックを使いたい場合など、明示的に三角行列を作りたいことがあります。
参考: NumPyの多次元配列から値が大きいn個のインデックスを取得する

自分で実装しても特に難しくないのですが、NumPyに専用関数が用意されていたのでこの記事ではそれを紹介したいと思います。

とりあえず、適当な行列を作って自分でやってみましょう。(実は実務では要素数が数万*数万みたいな巨大行列扱うことがあり、2重for文回すのはちょっと待つので嫌だなと思うのですが、小さい行列ならこれで十分です)

対角成分も0にする、狭義の下三角行列を作る場合は以下のようになるでしょうか。

import numpy as np

# 適当にデータを生成する
np.random.seed(4)
ary = np.random.randint(10, 100, size=(5, 5))
print(ary)
"""
[[56 65 79 11 97]
 [82 60 19 68 65]
 [65 67 46 60 54]
 [48 62 13 10 65]
 [31 31 83 48 66]]
"""

# 上三角成分を0にすることで下三角行列を作る
for i in range(ary.shape[0]):
    for j in range(i, ary.shape[1]):
        ary[i, j] = 0

print(ary)
"""
[[ 0  0  0  0  0]
 [82  0  0  0  0]
 [65 67  0  0  0]
 [48 62 13  0  0]
 [31 31 83 48  0]]
"""

はい、何も難しくなくできましたね。

これを2重のfor文を使わずにやる場合、NumPyには triuとtril というそれぞれ上三角行列と下三角行列を取り出すメソッドが用意されています。
参考:
numpy.triu — NumPy v1.22 Manual
numpy.tril — NumPy v1.22 Manual

とりあえず、動かしてみましょうか。2個目にkっていうオプションの引数(デフォルトは0)を取り、これを調整することで対角成分を残すかどうか、また対角成分に限らずどの斜めラインまで成分を残すかを調整できます。

# もう一回データを生成する
np.random.seed(4)
ary = np.random.randint(10, 100, size=(5, 5))

# 上三角行列
print(np.triu(ary))
"""
[[56 65 79 11 97]
 [ 0 60 19 68 65]
 [ 0  0 46 60 54]
 [ 0  0  0 10 65]
 [ 0  0  0  0 66]]
"""

# k=1とすると、対角成分も消える。(消える行が右上に広がる)
print(np.triu(ary, k=1))
"""
[[ 0 65 79 11 97]
 [ 0  0 19 68 65]
 [ 0  0  0 60 54]
 [ 0  0  0  0 65]
 [ 0  0  0  0  0]]
"""

# k=2とすると、さらに消える(消える行が右上に広がる)
print(np.triu(ary, k=2))
"""
[[ 0  0 79 11 97]
 [ 0  0  0 68 65]
 [ 0  0  0  0 54]
 [ 0  0  0  0  0]
 [ 0  0  0  0  0]]
"""

# k=-1とすると、逆に残す範囲が左下に広がる。より小さい負の数も同様。
print(np.triu(ary, k=-1))
"""
[[56 65 79 11 97]
 [82 60 19 68 65]
 [ 0 67 46 60 54]
 [ 0  0 13 10 65]
 [ 0  0  0 48 66]]
"""

# trilは下三角行列
print(np.tril(ary))
"""
[[56  0  0  0  0]
 [82 60  0  0  0]
 [65 67 46  0  0]
 [48 62 13 10  0]
 [31 31 83 48 66]]
"""

# tril の k=1 は残す範囲が広がる。境界線が右上にスライドするという意味ではtriuと同じ。
print(np.tril(ary, k=1))
"""
[[56 65  0  0  0]
 [82 60 19  0  0]
 [65 67 46 60  0]
 [48 62 13 10 65]
 [31 31 83 48 66]]
"""

# tril で対角成分も消したい場合はk=-1
print(np.tril(ary, k=-1))
"""
[[ 0  0  0  0  0]
 [82  0  0  0  0]
 [65 67  0  0  0]
 [48 62 13  0  0]
 [31 31 83 48  0]]
"""

狭義の三角行列(要するに対角成分も0)を取り出したい時、上三角行列(triu)の時はk=1で、下三角行列(tril)の時はk=-1 ってのがちょっと厄介ですね。まぁ、境界線が上に移動するか下に移動するかと考えるのが誤解が少ないかと思います。

三角行列といえば、NumPyには、tri という 三角行列を生成するメソッドもあります。(これもkという引数を取ります。)
参考: numpy.tri — NumPy v1.22 Manual
このtriで生成した三角行列を使ってマスクすることで、三角行列を作ることも可能です。というより、triuやtril の実装をみるとそういう作りになっています。
参考: triuのソースコード(GitHub)

NumPyのwhereメソッドとか使っていい感じに実装されていいますが、ぶっちゃけ掛け算(要素積)するだけで良いでしょう。

# tri で三角成分が1の三角行列を作れる
print(np.tri(5, k=-1))
"""
[[0. 0. 0. 0. 0.]
 [1. 0. 0. 0. 0.]
 [1. 1. 0. 0. 0.]
 [1. 1. 1. 0. 0.]
 [1. 1. 1. 1. 0.]]
"""

# これを要素積すると、元の行列は下三角行列になる。
print(ary * np.tri(5, k=-1))
"""
[[ 0.  0.  0.  0.  0.]
 [82.  0.  0.  0.  0.]
 [65. 67.  0.  0.  0.]
 [48. 62. 13.  0.  0.]
 [31. 31. 83. 48.  0.]]
"""

# 上三角行列が欲しいなら転置してから掛ける
print(ary * np.tri(5, k=-1).T)
"""
[[ 0. 65. 79. 11. 97.]
 [ 0.  0. 19. 68. 65.]
 [ 0.  0.  0. 60. 54.]
 [ 0.  0.  0.  0. 65.]
 [ 0.  0.  0.  0.  0.]]
"""

さて、最後に処理時間を見ておきましょう。最初に少しぼやいていましたが、2重for文のデメリットは巨大な行列を処理する場合の処理時間です。2万行*2万行(要素数4億)の行列で見てみましょう。

データ生成。

big_ary = np.random.randint(100, size=(20000, 20000))

まず、tirlです。こちら3秒ちょっとで終わりました。

% % time
big_ary_tril = np.tril(big_ary, k=-1)

"""
CPU times: user 1.18 s, sys: 1.33 s, total: 2.5 s
Wall time: 3.25 s
"""

次にfor文を回した場合です。こちらは50秒近くかかっています。もう少しで1分超えますね。

% % time
for i in range(big_ary.shape[0]):
    for j in range(i, big_ary.shape[1]):
        big_ary[i, j] = 0

"""
CPU times: user 45.3 s, sys: 1.59 s, total: 46.8 s
Wall time: 49.5 s
"""

速度、計算効率的にはtirl/triuを使った方が良いということが確認できました。

triu/trilは実装上は全ての要素に対して残すか0埋めするかの判定をかけているのに対し、for文の方は0で埋める要素だけにアクセスしているので、計算量というか演算回数だけならfor文の方が半分以下のはずですが、for文の方が圧倒的に遅いのが不思議です。NumPyのもっとコアな部分で並列処理がかなりしっかりと作り込まれているのでしょう。