sentencepieceを使ってみた(model_type: unigram / bpe / char)

このブログではテキストを単語に分割するときは概ねMeCabを使っていますが、実はMeCab以外にもテキストを分割する方法はいろいろあります。その中の一つであるsentencepieceを試してみたので紹介します。ちなみにこのsentencepiece、開発者はMeCabと同じ工藤拓さんです。
参考: SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing

sentencepieceとMeCabの違いとしては、MeCabは文章を文法的な意味を持つ最小単位の単語(形態素)に分割する形態素解析エンジンなのに対して、sentencepieceは特に文法的な意味を考慮せずに分割するということがあります。
MeCabは辞書をもとに文章を分割しますが、sentencepiece(unigram/ bpe)はそうではなく生の文章から自動的に分割する単位を学習し、語彙を習得します。
unigram とか bpe というのはそのときのアルゴリズムにつけられた名前です。
この他、model_type=”word”ってのがありますが、これは特殊で、あらかじめ単語に分割されたデータから低頻出語をさらに分割することで学習します。(学習データの準備が違うので次の記事で紹介します。)
これだけだと何もメリットなさそうなのですが、sentencepieceでは語彙の数を事前にパラメーターで指定することができ、例えば語彙を8000語に収めたいなら8000語で学習するといったことができます。BoWのような表現をする場合はもちろんですが、RNNやTransformer系の機械学習モデルへの前処理として使う場合、埋め込み層の語彙数を事前に指定しないといけないので非常に便利な特徴ですね。また、コーパス全体で1,2回しか出てこないような低頻度語を防げるのもありがたいです。

GitHubのPythonラッパーのサンプルコードを見ながら動かしてみましょう。
参考: sentencepiece/python at master · google/sentencepiece · GitHub

サンプルに使うデータは例によってライブドアニュースコーパスです。ニュース記事本文を使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

ちょっと使い方が独特で、まずモデルを学習するためのコーパスをテキストファイルで準備する必要があります。上記の記事で作ったライブドアニュースコーパスのCSVを、1記事1行のテキストに加工して保存しておきます。

import pandas as pd

# ライブドアニュースコーパスの本文を、1記事1行のテキストファイルとして書き出し
df = pd.read_csv("./livedoor_news_corpus.csv")
# 改行除去
df["text"] = df.text.str.replace("\n", " ")
# 全角空白除去 (blog記事上だと分かりにくいですが、全角スペースを半角スペースにしてます。)
df["text"] = df.text.str.replace(" ", " ")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")
# 前後の空白除去
df["text"] = df.text.str.strip()

# ファイル書き出し
with open('livedoor_corpus.txt', 'w') as w:
    for text_line in df["text"]:
        w.write(text_line + "\n")

これでコーパスができました。
このあと、sentencepieceのモデルを学習します。これもかなり使い方が特殊で、先ほど作ったテキストファイルと各種オプションを指定して学習を実行すると、モデルのバイナリ(.model)と、語彙(.vocab)の二つのファイルが出来上がります。ファイル名の拡張子以前の部分は model_prefix 引数で指定した文字列です。では早速、デフォルトの unigramモデルでやってみましょう。語彙数は適当に4000としました。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",  # コーパスファイル
    model_type="unigram",  # デフォルト
    model_prefix='livedoor_unigram',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

これで以下のように、livedoor_unigram.model/ livedoor_unigram.vocab ファイルが出来上がります。


$ ls
livedoor_corpus.txt
livedoor_unigram.model
livedoor_news_corpus.csv
livedoor_unigram.vocab

livedoor_unigram.vocabを開くと学習した語彙が見れます。

$ head -n 20 livedoor_unigram.vocab
<unk>	0
<s>	0
</s>	0
の	-3.17217
、	-3.41012
。	-3.81098
▁	-3.85549
を	-4.01854
が	-4.10413
に	-4.21314
は	-4.29892
で	-4.61122
と	-4.6203
」	-4.65722
「	-4.80934
も	-5.02828
な	-5.19927
)	-5.20129
(	-5.30642
い	-5.32545

先頭部分が1文字ばっかりで分かりにくいですが、ファイルの途中見ていくと、「する」とか「という」とかの単語もちゃんと出て来ます。<unk>,<s>,</s>の3単語がデフォルトで予約語とされていますが、この3語を含めて4000語です。control_symbolsやuser_defined_symbols という引数を使って、自分で定義したシンボルを入れることもできます。
参考: sentencepiece/special_symbols.md at master · google/sentencepiece · GitHub

さて、モデルが学習できたのでこれ使ってみましょう。出来上がったモデルファイルを読み込んで、それを使って文章をトークン化します。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_unigram.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp.encode(sample_text))
[6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]
# 文字列への分割
print(sp.encode_as_pieces(sample_text))
# ['▁', 'これから', 'の', '年', '度', '末', 'に', '向け', 'て', '、', '引', '越', 'し', 'を', '考え', 'ている', '人', 'も多い', 'の', 'ではない', 'だろうか', '?']

# id列を文章に戻す
print(sp.decode_ids([6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

これで、テキストをトークン化とその逆変換ができましたね。

モデルを学習したときの、model_type=”unigram” の部分を model_type=”bpe” とすることでもう一つのByte pair encodingアルゴリズムも試すことができます。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="bpe",
    model_prefix='livedoor_bpe',
    vocab_size=4000,
)

unigramとbpe、そんなに大きな違いないんじゃ無いかなという予想に反して、出来上がった語彙ファイルを見ると全然違います。

$ head -n 20 livedoor_bpe.vocab
<unk>	0
<s>	0
</s>	0
てい	-0
した	-1
った	-2
する	-3
▁・	-4
して	-5
ない	-6
ている	-7
から	-8
こと	-9
って	-10
os	-11
とい	-12
ます	-13
され	-14
です	-15
ック	-16

最初っから2文字ペアの単語がたくさん出て来ますね。
学習したモデルを読み込んで使う方法は同じです。語彙が違うので微妙に結果が変わります。
たった一文の比較で優劣つけるわけにはいきませんが、この例文だとなかなかどちらがいいとも言い難いです。

# モデルの読み込み
sp_bpe = spm.SentencePieceProcessor(model_file='./livedoor_bpe.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp_bpe.encode(sample_text))
[1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]
# 文字列への分割
print(sp_bpe.encode_as_pieces(sample_text))
# ['▁これ', 'からの', '年', '度', '末', 'に向', 'けて', '、', '引', '越', 'し', 'を', '考え', 'ている', '人も', '多い', 'のではない', 'だろうか', '?']

# id列を文章に戻す
print(sp_bpe.decode_ids([1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

「実践・自然言語処理シリーズ2 形態素解析の理論と実装 (近代科学社/ 工藤拓(著))」に少しだけsentencepieceについての記述もあり、unigramとbpeの違いが少し記載されています。
そのまま引用します。

BPEは、ニューラル翻訳に標準的に用いられている手法であり、1文字1語彙から開始し、連結した際に最も頻度が高くなる二つの語彙を選び新たな語彙とする手続きを決められた語彙サイズに達するまで繰り返すことで語彙結合ルールを学習します。
(中略)
ユニグラム言語モデルは、テキストを符号化するときの符号長が最小となるように、分割モデルをEM法を用いて学習します。

なるほど、って感じですね。個人的にはBPEの方が具体的なアルゴリズムがわかりやすいです。

ちなみに、ユニグラム言語モデルは符号化したときの符号長を最小にするように分割するって書いてあるので、ユニグラムモデルの方が少ない単語数になるのかと思って、学習したテキストで試したのですが結果は逆でした。不思議です。

print(df.text.apply(lambda x: len(sp.encode(x))).mean())
# 814.231301751052

print(df.text.apply(lambda x: len(sp_bpe.encode(x))).mean())
# 811.67992398534

今回の記事の主題はほぼここまでなのですが、model_typeには次の記事で取り上げるwordの他にもcharってのがあるので、一応これも紹介しておきます。

これは非常に単純なやつでして、単純にテキストを文字単位に分割します。学習するのはコーパスに登場した文字の一覧だけです。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="char",
    model_prefix='livedoor_char',
    vocab_size=4000,
)

学習結果の語彙も予約語以外は文字単体だけです。4000種類もなかったので行数も少なくなりました。

$ head -n 20 livedoor_char.vocab
<unk>	0
<s>	0
</s>	0
の	-3.64481
▁	-3.64584
、	-3.80562
い	-3.93749
ー	-4.02605
に	-4.07836
る	-4.1384
と	-4.15518
で	-4.20032
し	-4.23772
な	-4.24788
。	-4.24846
た	-4.2548
て	-4.2787
を	-4.28831
が	-4.29299
は	-4.38982

モデルを読み込んで使う方法はunigramやbpeと同じなので略します。

WordCloudをHTML出力し、各単語にリンクを設定する

このブログで何度かワードクラウドを作って来ましたが、ちょっとした興味からそれを画像ではなくHTML出力したいと思いやってみました。
そして、単にHTML出力をするだけでなく、ワードクラウド中の単語をクリックしたら単語ごとに指定したURLにジャンプできるようにします。

この記事のサンプルではその単語でのGoogle検索結果へアクセスできるようにします。ワードクラウド自体はいつものライブドアニュースコーパスのデータで作ります。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

実はいつも使っているワードクラウドのライブラリ(wordcloud)には、to_html()というメソッドが形だけあって、中身が実装されていないという状態でした。僕はこれが実装されるのを待っていたのですが、残念ながら最近の更新でこのメソッド自体が消されてしまいました。
参考: remove empty to_html in favor of to_svg (#607) · amueller/word_cloud@be9bb5e · GitHub

HTML出力機能はもう実装されないんだと思い、wordcloudのオブジェクトが持っているlayout_プロパティ(単語や配置場所、色などがまとまった配列)を読み解いて、自分でHTMLを作るコードを書くしかないと思ってコーディングに着手していました。
しかし改めてライブラリのコードをよく見るとto_svg()なるメソッドを持っているではないですか。これを使えばSVGで書き出せるので、簡単にHTMLに埋め込めます。
参考: wordcloud.WordCloud — wordcloud 1.8.1 documentation

SVGで書き出せたらあとはリンクを貼るだけなので簡単です。textタグたちをaタグで囲むだけです。ただ、今回はJavaScriptの addEventListener で、click したら指定のURLを開く関数をセットしました。

前置きが長くなって来たので、ここからやってみます。まずは普通にワードクラウドを作ります。

import re
import subprocess
import unicodedata
import pandas as pd
import MeCab
from wordcloud import WordCloud


# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")
# エラーになる文字があるので取り除く (ライブドアニュースコーパス使う場合だけの処理。普通は不要)
df.text = df.text.str.replace("\u2028", "")

# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic")

# ひらがなのみの文字列にマッチする正規表現
kana_re = re.compile("^[ぁ-ゖ]+$")

# 分かち書きして、ひらがな以外を含む 名詞/動詞/形容詞 を返す関数
def mecab_tokenizer(text):
    # 分かち書き
    parsed_lines = tagger.parse(text).splitlines()[:-1]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 品詞を取得
    pos = [f.split(',')[0] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    # 名詞/動詞/形容詞に絞り込み
    target_pos = ["名詞", "動詞", "形容詞"]
    token_list = [t for t, p in zip(token_list, pos) if (p in target_pos)]
    # ひらがなのみの単語を除く
    token_list = [t for t in token_list if not kana_re.match(t)]
    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]
    # 半角スペースを挟んで結合する。
    result = " ".join(token_list)
    # 念のためもう一度ユニコード正規化
    result = unicodedata.normalize("NFKC", result)
    return result


# 形態素解析
df["tokens"] = df.text.apply(mecab_tokenizer)
# wordcloud入力データ用に連結する
text_data = " ".join(df["tokens"])

# ワードクラウドのオブジェクト生成
wc = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",  # 日本語フォントファイル
        width=600,  # 幅
        height=400,  # 高さ
        prefer_horizontal=0.9,  # 横書きで配置することを試す確率 (デフォルト0.9)
        background_color='white',  # 背景色
        include_numbers=False,  # 数値だけの単語を含まない
        colormap='tab20',  # 文字色のカラーマップ指定
        regexp=r"\w{2,}",  # 2文字以上の単語のみ含む
        relative_scaling=1,  # 頻度のみで文字サイズを決める
        collocations=False,  # bi-gramを考慮しない
        max_font_size=60,  # 最大フォントサイズ
        random_state=42,  # 乱数の初期値
    ).generate(text_data)

# この時点で作成できたwordcloudを確認する場合は以下の関数を実行。 
wc.to_image()
# 出力省略

さて、これでワードクラウドができました。これを to_svg() で SVGに出力するとどうなるか見てみましょう。

# to_svg() で svgの文字列を生成できる
print(wc.to_svg())
"""
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">
<style>text{font-family:'IPAexGothic';font-weight:normal;font-style:normal;}</style>
<rect width="100%" height="100%" style="fill:white"></rect>
<text transform="translate(342,63)" font-size="60" style="fill:rgb(127, 127, 127)">思う</text>
<text transform="translate(391,134)" font-size="44" style="fill:rgb(127, 127, 127)">映画</text>
<text transform="translate(239,359)" font-size="41" style="fill:rgb(174, 199, 232)">自分</text>
<text transform="translate(458,55)" font-size="39" style="fill:rgb(174, 199, 232)">記事</text>

 # 中略
 
<text transform="translate(411,86)" font-size="12" style="fill:rgb(158, 218, 229)">一番</text>
<text transform="translate(408,50)" font-size="12" style="fill:rgb(23, 190, 207)">転職</text>
<text transform="translate(58,277)" font-size="12" style="fill:rgb(255, 152, 150)">得る</text>
</svg>
"""

引数にファイル名の指定などはできず、SVGタグで囲まれた文字列を返してくれるようです。
これを .svg の拡張子で保存すると、ワードクラウドのSVGファイルが出来上がります。
先頭に、”<!DOCTYPE HTML>”をくっつけて、.htmlファイルで保存してもよいでしょう。
文字列にリンクを設定するなどの要件が無いのであれば、これをファイルに保存して完成です。

ここからリンクを設定していきます。リンク先は今回はお試しなので、Google検索結果にします。URLは、 https://www.google.com/search?q={検索キーワード} です。

前の方でも書きましたが、リンクっていう観点だとaタグで囲むのがセオリーですし、テキストを加工するスクリプトを描くのもそんなに難しくありません。ただ、文字列の加工の総量を少なめにしたいのと、イベントリスナーを使う方法の方がページ遷移させる以外の用途への応用も多いと思うので、こちらを採用しました。

Google検索結果のURLの検索キーワードはパーセントエンコーディングする必要があるので、JavaScriptのencodeURI関数を使います。僕はあまりJavaScript得意ではないので自信がないですが、次のようなコードでいかがでしょうか。
(無理矢理PythonでJavaScriptを書き出してますが、一旦SVGを書き出してテキストエディタでスクリプトを書き込んでも良いと思います。)

# textタグたちに、クリック時にGoogle検索結果を開くイベントリスナーを追加するJavaScript
link_script = """
<script>
    svg = document.getElementsByTagName("svg")[0];
    text_tags =  svg.getElementsByTagName("text")
    for(var i=0; i<text_tags.length; i++){
        text_tags[i].addEventListener(
            "click",
            function(){
                word = this.textContent;
                word_uri = encodeURI(word);
                url = "https://www.google.com/search?q=" + word_uri;
                window.open(url, "_bkank");
            }
        )
    }
</script>"""

# HTMLファイルに書き出し
with open("word_cloud.html", "w") as f:
    f.write("<!DOCTYPE HTML>\n")
    f.write(wc.to_svg())
    f.write(link_script)

出力されるhtmlファイルが次です。

ブログに公開できるデータということで、無難なデータと無難なリンク先で作っていますが、これはデータと使い方によっては結構面白いものを作れる可能性を感じませんか?

pyvisでネットワーク可視化

このブログではネットワーク(グラフ)の可視化にはNetworkX(+Matplotlib)やGraphvizを使って来たのですが、最近pyvisというライブラリを気に入っていてよく使うようになりました。
訪問者の方にもぜひ触ってみていただきたいので軽く紹介していきます。

まず、どんなものが描けるのかの例です。このpyvisはvis.jsというJavaScriptのライブラリのPythonラッパーになっています。 そのため、vis.jsのドキュメントの examples ページを見るとさまざまな可視化例が載っています。こんなのが作れるんだ!ってモチベーションが上がるので一度見ていただけると幸いです。(実はこのvis.jsはネットワークの可視化以外の機能も持っているのですが、もっぱら僕の関心はネットワークの可視化にあります。)
参考: Vis Network Examples

各サンプルなのですが、それぞれのサンプル個別のページのアクセスして触っていただけると分かる通り、このライブラリで描いたネットワーク図はノードをマウスでドラッグして動かせます。これが1番のお勧めポイントです。また、単一のHTMLファイルで結果が出力されるので仕事で可視化した場合は結果は他の人に共有しやすいというのもいいですね。

それでは早速、使ってみましょう。pyvis 自体のドキュメントはこちらですが、母体のvis.jsのドキュメントや、Examplesのソースコードなども目を通されると理解が深まると思います。

本当に適当に作ってみました。インスタンス作って、ノードとエッジを追加して、最後HTML出力のメソッドを呼び出して完成です。

from pyvis.network import Network


# ネットワークのインスタンス生成
network = Network(
    height="300px",  # デフォルト "500px"
    width="400px",  # デフォルト "500px"
    notebook=True,  # これをTrueにしておくとjupyter上で結果が見れる
    bgcolor='#ffffff',  # 背景色。デフォルト "#ffffff"
    directed=True,  # Trueにすると有向グラフ。デフォルトはFalseで無向グラフ
)

# add_node でノードを追加
# label は省略可能。省略するとidと同じになる。
network.add_node(n_id=1, label=1, shape="circle")
network.add_node(n_id=2, label=2, shape="box", color="green")
network.add_node(n_id=3, label=3, shape="triangle")
network.add_node(n_id=4, label=4)  # shape を省略すると、 shape="dot"と同じになる

# add_edge でエッジを追加
network.add_edge(1, 2,)
network.add_edge(2, 4, width=2)  # width で太さを変えられる
network.add_edge(3, 4, smooth="dynamic")  # smooth を指定することで、エッジを曲線にできる
network.add_edge(4, 3, smooth="dynamic")  # エッジを曲線にすると双方向のエッジを別の線にできる。(直線だと重なる)

# 指定したファイル名でHTMLを出力。
network.show("pyvis_sample1.html")

出来上がりはこんな感じです。

動かせるっていいですね。

ソースコード中にコメントをいろいろ書いてますが、ノードもエッジも細かい設定がたくさんできます。pyvisのドキュメントだとこのページが該当するのですが、そこまで丁寧ではないので、vis.jsの方のドキュメントを読み込んで、そこから類推して使った方が理解が早いかもしれません。(場合によっては出力されたHTMLファイルを直接編集するとか。)
vis.js の方のドキュメントでは以下のページに設定可能な項目がまとまっています。
vis.js – Nodes documentation.
vis.js – Edges documentation.

もう一つ、HTML出力前に network.show_buttons() ってメソッドを実行しておくと、大量のコントロールボタンがHTMLに出力されます。(jupyterでは表示されないので、別途ブラウザでHTMLファイルを開いてください。)
これを使うと、ノードやエッジのオプションやノード配置の計算に使われている力学の設定などをインタラクティブに変更できます。

# ネットワークの作成までのコードは共通
network.show_buttons()  # 各種ボタンの表示
network.show("pyvis_sample2.html")

これも楽しいので試してみてください。(ただ、楽しいだけで今のところ有効な使い方を見出せずすみません。) ボタン多すぎ、って場合は、ドキュメントにある通り、filter_ って引数で絞り込めます。

最後に、vis.js から pyvis に移行した人への注意が一つあります。(僕はこのパターン。当初直接HTMLでvis.jsファイル書いてました。)
それは、 node の shape のデフォルト値で、 HTML/JavaScriptで書いてる場合、shapeを何も指定しなかった場合の挙動は”ellipse”です。しかし、pyvis は add_nodeでshape 引数を省略すると、出力されるJavaScirptに勝手に “shape”: “dot” をつけます。

何も指定せずに超単純なネットワークを描いたときの挙動が違うので僕は結構戸惑いました。
(ellipseは図の内側にラベルが入り、dotはラベルが外に出るという違いがちょっと気に入らなかった。その辺の話も、上記の Nodes documentation に書いてあります。)

PythonでWikipediaの情報を取得する

仕事で諸事情あり、Wikipediaの情報を取得したいことがあったのでその方法を紹介します。
最初はrequestsなどのHTTPクライアントでデータを取ってきて頑張ってHTMLをパースしようかと思っていたのですが、実はPythonでWikipediaを利用するには専用のライブラリが公開されています。それがこちらの wikipedia です。 PyWikipediaとかじゃなくて そのままの名前なのですね。
参考: PyPIのページ wikipedia · PyPI
ドキュメント Wikipedia — wikipedia 0.9 documentation

どうやら、Wikipedia本体がそのそも、Media Wiki API というAPIを公開してくれていて、それをラップしているようです。

APIにはかなり多様な機能が実装されているようですが、いったん僕の用途としては用語の検索と、該当ページのコンテンツの取得の二つができれば良いのでその点に絞って使い方を紹介していきます。

一番シンプルな使い方は Quickstart ページを見るのが良いでしょう。
とりあえず、適当な単語で検索してみます。僕たちは日本語を使うので、最初に言語を設定します。(設定しない場合はデフォルトの言語は英語で、日本語の単語を検索したとしても英語版Wikipedia内で検索されます。)

import wikipedia


wikipedia.set_lang("ja")
print(wikipedia.search("データ分析"))
"""
['データ解析',
 'データ',
 '慶應義塾大学パネルデータ設計・解析センター',
 'データベース',
 'データ・クラスタリング',
 'ビッグデータ',
 '主成分分析',
 '分散分析',
 'NTTデータ',
 '精神分析学']
"""

言語を設定した後、「データ分析」で検索してマッチしたページのタイトルの一覧が取得できましたね。(実際のWikipediaで検索すると、一番目の候補のデータ解析のページにリダイレクトされます。)

set_lang() で設定できる略号(上の例で言えばja)と言語(上の例では日本語)の対応は、languages()ってメソッドで一覧が取れます

print(wikipedia.languages())
"""
{'aa': 'Qafár af',
 'ab': 'аԥсшәа',
 'abs': 'bahasa ambon',
 'ace': 'Acèh',
 'ady': 'адыгабзэ',
 'ady-cyrl': 'адыгабзэ',
 'aeb': 'تونسي/Tûnsî',
 'aeb-arab': 'تونسي',
 # 以下略
"""

さて、先ほどのsearchの結果、「データ解析」ってページがあることがわかったので具体的にそのページのコンテンツを取得してみましょう。それには、page というメソッドを使います。結果は、WikipediaPageというオブジェクトで取得でき、タイトルや中身をその属性として持ちます。とりあえず、タイトルとサマリーを表示してみます。

wp = wikipedia.page("データ解析")

print(wp.title)
# データ解析

print(wp.summary)
"""
データ解析(データかいせき、英: data analysis)は、データ分析(データぶんせき)とも呼ばれ、
有用な情報を発見し、結論を報告し、意思決定を支援することを目的として、データを検査し、
クリーニングや変換を経て、モデル化する一連のプロセスである。データ解析には多数の側面とアプローチがあり、
色々な名称のもとで多様な手法を包含し、ビジネス、科学、社会科学のさまざまな領域で用いられている。
今日のビジネス界において、データ解析は、より科学的な意思決定を行い、ビジネスの効率的な運営に貢献する役割を担っている。
データマイニングは、(純粋な記述的な目的ではなく)予測的な目的で統計的モデリングと
知識獲得に重点を置いた固有のデータ解析技術である。
これに対し、ビジネスインテリジェンスは、主にビジネス情報に重点を置いて、集計に大きく依存するデータ解析を対象としている。
統計学的な用途では、データ解析は記述統計学 (en:英語版) 、探査的データ解析(EDA)、確認的データ解析(仮説検定)(CDA)に分けられる。
EDAはデータの新たな特徴を発見することに重点を置き、CDAは既存の仮説の確認または反証に焦点を当てる。
予測分析は、予測的な発生予報あるいは分類のための統計モデルの応用に重点を置き、
テキスト分析は、統計的、言語的、および構造的な手法を用いて、非構造化データの一種であるテキストデータから情報を抽出し知識の発見や分類を行う。
上記はどれも、データ解析の一種である。
データ統合はデータ解析の前段階であり、データ可視化およびデータ配布はデータ解析と密接に関連している。
"""

ページのコンテンツ全体は、content プロパティに持っていて、またhtml()メソッドを使うと、そのページのHTML全体を取得することもできます。

wp.content  # コンテンツ全体
wp.html()  # HTMLが取得できる

どちらも結果が非常に大きいので出力は省略します。

このほか、そのページのurl (wp.url)やリンクされている他の単語の一覧(wp.links)などの属性も持っています。どんな属性があるのかの一覧はドキュメントの、class wikipedia.WikipediaPage の部分を見ていただくのが良いと思います。

上記では、まず、wikipedia.page() で ページオブジェクトを取得して、その後そこからsummaryを取得しましたが、実は summaryを直接取得することもできます。それは単に、 wikipedia.summary()を使うだけです。(contentにはこれはないんですよね。summary専用の機能と考えて良さそうです。)

# summaryは直接取得できる
print(wikipedia.summary("データ解析"))
"""
データ解析(データかいせき、英: data analysis)は、 # 長いので以下略
"""

# 実はデータ分析、で検索しても勝手にリダイレクトしてデータ解析の結果を返してくれる
print(wikipedia.summary("データ分析"))
"""
データ解析(データかいせき、英: data analysis)は、 # 長いので以下略
"""

これで非常に簡単にWikipediaのデータが使えるようになりました。
ただ、一点気をつけないといけないことがあります。それは、検索すると曖昧さ回避のページに飛ぶような単語で検索する場合です。例えば、地名、人名、企業名、等々で使われる「豊田」でやってみると、DisambiguationError という例外が発生します。ライブラリが例外オブジェクトを作ってくれているのでそれでキャッチできます。

try: 
    wp = wikipedia.page("豊田")
except wikipedia.exceptions.DisambiguationError as e:
    print(e)
    
"""
"豊田" may refer to: 
豊田市
豊田町 (曖昧さ回避)
豊田町 (山口県)
豊田町 (静岡県)
豊田村 (曖昧さ回避)
豊田郡 (曖昧さ回避)
豊田郡
豊田 (名古屋市)
豊田地区
豊田 (日野市)
豊田 (紀の川市)
豊田鎮 (通遼市)
豊田鎮 (南靖県)
豊田郷 (彰武県)
豊田郷 (新寧県)
フォンディエン県 (トゥアティエン=フエ省)
フォンディエン県 (カントー)
豊田駅
豊田駅 (北海道)
豊田駅 (花蓮県)
新豊田駅
新豊田駅
三河豊田駅
豊田市駅
上豊田駅
肥後豊田駅
豊田町駅
豊田本町駅
トヨタグループ
トヨタ自動車
豊田自動織機
トヨタ紡織
豊田通商
豊田中央研究所
豊田合成
豊田鉄工
豊田スタジアム
豊田 (飲食業)
豊田エリー
豊田清
豊田順子
豊田孝治
豊田真由子
豊田萌絵
豊田泰光
豊田穣
豊田陽平
豊田ルナ
豊田佐吉
豊田喜一郎
豊田英二
豊田章男
豊田インターチェンジ
豊田市#道路
豊田 (小惑星)
豊田館跡
豊田ナンバー
豊田工業大学
^
「豊田」で始まるページの一覧
"""

用途が多様な単語で使う場合は気をつけるようにしましょう。

Jupyterのウィジェットを使ったアノテーションツール

前回の記事で紹介したJupyter Notebookのウィジェット(ボタン)の簡単な応用事例です。
参考: Jupyter Notebook でボタンを使う

この記事でいくつか事例を紹介しようと思いますが、まずは機械学習等でテキストデータの分類モデルをつくつる時の教師データ作成(アノテーション)のツールを作ってみようと思います。
何かしらテキストデータを受け取って、事前に定義されたいくつかのカテゴリ(ラベル)の中から1個選んで、「これはカテゴリ0だ」とか「こいつはカテゴリ3だ」みたいなのを記録してくツールですね。

とりあえず、使うデータの準備と必要なライブラリを読み込んでおきます。サンプルなのでテキストの内容自体は適当です。

import pandas as pd
import ipywidgets as widgets
from IPython.display import display


# サンプルとして適当なテキストの集合を作る
df = pd.DataFrame(
    {
        "text": [f"ラベル付されるテキストその{i:03d}" for i in range(100)],
        "label": None
    }
)
print(df.head())
"""
               text label
0  ラベル付されるテキストその000  None
1  ラベル付されるテキストその001  None
2  ラベル付されるテキストその002  None
3  ラベル付されるテキストその003  None
4  ラベル付されるテキストその004  None
"""

前回の記事ではあまり名前空間のことを考えずにいろいろ書いていましたが、各種ウィジェットがグローバルスコープにあると予期せぬ挙動につながったりもするので、今回はアノテーションのクラスを定義します。
コードはやや長いですがやっていることはシンプルで、渡されたラベルの数だけボタンを作り、データフレーム内のテキストデータを1つ表示し、ボタンが押されたらボタンに対応したlabelのidをデータフレームに書き込んで、また次のテキストを表示するというそれだけです。
そして、全データ処理し終わった時に、ボタンを非活性化する処理も入れています。

今回はサンプルデータが適当で、データ中に何番目のテキストなのか入ってますが、それとは別に i 番目のデータだって表示する機能も入れています。実務では何かしら進捗がわかる機能をつけた方が良いです。どこまで進んだかわからない長いアノテーションは心が折れます。

class annotation():

    def __init__(self, df, labels):
        self.data = df
        self.i = 0  # 何番目のデータをアノテーションしているかのカウンタ
        self.buttons = [widgets.Button(description=label) for label in labels]
        self.output = widgets.Output()

        # ボタンにvalueプロパティを持たせておく
        for j in range(len(self.buttons)):
            self.buttons[j].value = j

        # ボタンにクリック寺の処理を追加
        for button in self.buttons:
            button.on_click(self.select_label)

        # ツールの初期表示実行
        self.display_tools()

    def display_tools(self):
        # ボタンや出力領域の初期表示
        self.hbox = widgets.HBox(self.buttons)
        self.output.clear_output()
        display(self.hbox, self.output)
        with self.output:
            print(f"{self.i}番目のデータ:\n")
            print(self.data.iloc[self.i]["text"])

    def select_label(self, button):
        # ボタンを押した時の処理
        # データフレームにセットされたボタンのvalueを書き込む
        self.data.iloc[self.i]["label"] = button.value
        # 次のデータに移行
        self.i += 1
        # 次のデータを表示
        self.output.clear_output(True)

        # 全データ処理し終わったら完了
        if self.i >= len(self.data):
            with self.output:
                print("完了!")
                for button in self.buttons:
                    button.disabled = True  # ボタンを非活性化
            return

        with self.output:
            print(f"{self.i}番目のデータ:\n")
            print(self.data.iloc[self.i]["text"])

上で作ったクラスは次のように使います。

labels = ["カテゴリー0", "カテゴリー1", "カテゴリー2", "カテゴリー3"]
tool = annotation(df, labels)

こうすると次の画像のようにボタンが表示され、クリックするごとにデータフレームに値が書き込まれていきます。

適当のぽちぽちとボタンを押していくと、DataFrameに次のように値が入ります。

print(df.head())
"""
               text label
0  ラベル付されるテキストその000     1
1  ラベル付されるテキストその001     2
2  ラベル付されるテキストその002     0
3  ラベル付されるテキストその003     3
4  ラベル付されるテキストその004     0
"""

ボタンの配置をもっと整えたり、スキップボタン、戻るボタンの実装などいろいろカスタマイズは考えられますが、一旦これで最低限の役割は果たせると思います。

次にもう一つ、MeCabのユーザー辞書作成の補助ツールを作っておきます。
これは、ボタンを押したらそのボタンに紐づいたラベルが記録されるのではなく、
ドロップダウンで品詞情報を選び、さらに、原形、読み、発音を手入力で入れて、確定ボタンを押したら単語辞書作成用の配列に結果格納するというものです。

今回の記事はお試しなので、追加する単語は適当にピックアップした数個ですが本来はそのリストを作らないといけないので、こちらの記事を参考にしてください。
参考: gensimでフレーズ抽出
また、アウトプットのMeCabユーザー辞書の書式はこちらです。
参考: MeCabでユーザー辞書を作って単語を追加する

ではやってみます。今回も各種パーツを一つのクラスにまとめます。

class create_dictionaly():
    def __init__(self, new_words):
        self.data = new_words  # 辞書に追加する候補のワード
        self.i = 0  # カウンタ
        self.results = []  # 結果格納用の配列

        # 確定とスキップの2種類のボタンを用意する
        self.decision_button = widgets.Button(description="確定")
        self.decision_button.on_click(self.decision_click)
        self.skip_button = widgets.Button(description="スキップ")
        self.skip_button.on_click(self.skip_click)

        # 品詞の選択機能(サンプルコードなのでIPA辞書の品詞の一部だけ実装)
        self.pos_list = [
            "名詞,一般,*,*,*,*,*",
            "名詞,固有名詞,一般,*,*,*,*",
            "名詞,サ変接続,*,*,*,*,*",
            "名詞,ナイ形容詞語幹,*,*,*,*,*",
            "名詞,形容動詞語幹,*,*,*,*,*",
            "名詞,固有名詞,人名,一般,*,*,*",
            "名詞,固有名詞,人名,姓,*,*,*",
            "名詞,固有名詞,人名,名,*,*,*",
            "名詞,固有名詞,組織,*,*,*,*",
        ]
        self.pos_select = widgets.Dropdown(options=self.pos_list)

        # 原形, 読み, 発音を設定する項目
        self.base = widgets.Text(description="原形: ")
        self.reading = widgets.Text(description="読み: ")
        self.pronunciation = widgets.Text(description="発音: ")

        # 次の単語候補表示場所
        self.new_word = widgets.Output()

        self.display_tools()

    def display_tools(self):
        # ボタンや出力領域の初期表示
        self.text_hbox = widgets.HBox(
            [self.base, self.reading, self.pronunciation])
        self.button_hbox = widgets.HBox(
            [self.decision_button, self.skip_button])
        display(self.new_word, self.text_hbox,
                self.pos_select, self.button_hbox)
        # 最初の単語を表示しておく
        self.next_word()

    def next_word(self):
        # 次の単語の表示
        # 全データ処理し終わったら完了
        if self.i >= len(self.data):
            self.new_word.clear_output(True)
            with self.new_word:
                print("完了!")
                self.decision_button.disabled = True
                self.skip_button.disabled = True
            return

        self.word = self.data[self.i]

        self.new_word.clear_output(True)

        with self.new_word:
            print(self.word)

        self.base.value = self.word
        self.reading.value = ""
        self.pronunciation.value = ""
        self.pos_select.value = self.pos_list[0]
        self.i += 1

    def decision_click(self, button):
        # 確定ボタンクリック
        # MeCabユーザー辞書の形式のテキストを生成
        result_text = f"{self.word},,,,{self.pos_select.value},"
        result_text += f"{self.base.value},{self.reading.value},{self.pronunciation.value}"
        # 結果の一覧に格納
        self.results.append(result_text)
        # 次の単語表示
        self.next_word()

    def skip_click(self, button):
        # スキップボタンクリック
        # 次の単語表示
        self.next_word()

実行は次のコードです。

cd_tool = create_dictionaly(["スマホ", "クラウド", "ガジェット", "インターフェース", "ブログ"])

このように、単語候補が出現し、原形(一旦そのままの値で補完)と読み、発音を入力して、品詞を選んで確定を押すと辞書用のデータが記録されていく仕組みになっています。

結果は、cd_tool.result に入ってます。

print("\n".join(cd_tool.results))
"""
スマホ,,,,名詞,一般,*,*,*,*,*,スマホ,スマホ,スマホ
クラウド,,,,名詞,一般,*,*,*,*,*,クラウド,クラウド,クラウド
ガジェット,,,,名詞,一般,*,*,*,*,*,ガジェット,ガジェット,ガジェット
インターフェース,,,,名詞,一般,*,*,*,*,*,インターフェース,インターフェース,インターフェース
ブログ,,,,名詞,一般,*,*,*,*,*,ブログ,ブログ,ブログ
"""

これをテキストファイルに書き出せば、ユーザー辞書のseedデータになります。

原形/読み/発音はテキストボックスを一つにまとめて自分でカンマを打つ方が早いかもと思ったり、全体的に配置のデザインがイケて無いなとか思うところはあるのですが、この先徐々に改良していきたいと思います。

以上の二つの例で、ボタンの結果をそのまま記録していくパターン、何かしらのウィジェットでデータを入力してボタンを押して確定するパターンの二つを紹介できたので、ラベル付のタスクであれば、これらの組み合わせで大抵は対応できるのではないでしょうか。

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のもっとコアな部分で並列処理がかなりしっかりと作り込まれているのでしょう。

NumPyの多次元配列から値が大きいn個のインデックスを取得する

NumPyの多次元配列(といってもここで扱うのは2次元の行列ですが)の要素の中から、値が大きい順に数個取り出して、そのインデックスが欲しいことがありました。
これが多次元ではなく、1次元の配列だったら、argsort使えば一発です。
参考: numpyのarrayを並び替えた結果をインデックスで取得する

多次元になるとargsordだけではうまく動きません。
まともに実装すると、やや手間だったのですが、unravel_index というメソッドを使うとうまく書けたので紹介します。

とりあえず参考に使うデータを用意しておきます。実際はもっと巨大なデータでやったのですが、この記事では結果を確認しやすいように 5*5の行列の25個の要素から上位5要素のインデックスを取得することを目指します。
まずデータを作っておきます。

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]]
"""

このデータから値が大きい順に5このインデックスを取得することを目指します。もし、欲しいのがインデックスではなく値であればこれは簡単です。ravel() か flatten()、reshape()あたりで1次元に変形してソートするだけです。こんな感じに。

np.sort(ary.ravel())[-5:]
# array([68, 79, 82, 83, 97])

今回の用件では欲しいのは、これらの値が入ってたインデックスです。つまり、
[[1, 3], [0, 2], [1, 0], [4, 2], [0, 4]] を求めたいのです。

単純に argsortすると、行ごとにその行内でソートして結果を返してくれます。
また、axis引数にNoneを指定すると行列全体でソートしてくれるのですが結果が、ravel等で1次元に直した後にargsortしたようなイメージで帰ってきます。

print(np.argsort(ary))
"""
[[3 0 1 2 4]
 [2 1 4 3 0]
 [2 4 3 0 1]
 [3 2 0 1 4]
 [0 1 3 4 2]]
"""

print(np.argsort(ary, axis=None))
"""
array([18,  3, 17,  7, 21, 20, 12, 15, 23, 14,  0, 13,  6, 16,  9, 19,  1,
       10, 24, 11,  8,  2,  5, 22,  4])
"""

axis=Noneの方の結果(小さい順なので最後の5つ、[8, 2, 5, 22, 4]が求める5つの数のインデックス)を元の(5*5)の配列に読み替えると欲しい結果が得られることになります。
1次元でインデックスが8ってことは9番目の要素で、5*5行列の9個目の要素は[1, 4]だ、同様に2は[0, 2]、5は[1, 0] と読み替えていくわけですね。割り算と余りを使って実装できそうです。やってみると次のようになります。

for i in np.argsort(ary, axis=None)[-5:]:
    print([i//5, i%5])

"""
[1, 3]
[0, 2]
[1, 0]
[4, 2]
[0, 4]
"""

上記の変換をあらかじめ用意されたメソッドで行うのが、この記事冒頭で名前を出した unravel_index です。
ドキュメント: numpy.unravel_index — NumPy v1.22 Manual

動かしてみます。

# 以下の引数を渡していることに注意して見てください。
# np.argsort(ary, axis=None)[-5:] = [ 8  2  5 22  4]
# ary.shape = (5, 5)

print(np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape))
# (array([1, 0, 1, 4, 0]), array([3, 2, 0, 2, 4]))

かなり近いところまで来ましたね。最終的な結果が二つのarrayからなるタプルになっていますが、それぞれから1個ずつ値を取り出してペアにすれば欲しい結果になっています。

人間が見るには微妙に扱いにくい形に見えるのですが、次のように値を取り出すのに使えます。

index_list = np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape)
print(ary[index_list])
# [68 79 82 83 97]

今回はインデックスの方が欲しかったので、これを人が見ても見やすい形にするため、vstackして、さらに転置(.T)しても良いでしょう。

print(np.vstack(index_list).T)
"""
[[1 3]
 [0 2]
 [1 0]
 [4 2]
 [0 4]]
"""
# 以下のように書けば1行で可能。
print(np.vstack(np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape)).T)

以上で欲しかった結果が得られました。unravel_indexがちょっと馴染みが薄かったのでまだ慣れないのですが、個人的にはこれが一番いいのではないかと思います。

大き方から順ではなく、小さい方からとたい、って時は、argsortに対するスライスの部分を少し変えるだけで対応できます。([-5:]ではなく[:5]に。)

ちなみに、argsort/ unravel_index/ vstack など少々マイナーなメソッドをいくつも使うのが嫌な場合は次のような方法もあります。多次元の配列を、インデックスと値のペアのようなデータ構造に変換してソートするやり方です。

# インデックスと値を組にしたデータにする。
lil_ary = [(i, j, ary[i, j]) for i in range(ary.shape[0]) for j in range(ary.shape[1])]
print(lil_ary[:5])  # 中身を一部確認
# [(0, 0, 56), (0, 1, 65), (0, 2, 79), (0, 3, 11), (0, 4, 97)]

# 3番目の要素(indexは2)をキーにしてソート
lil_ary.sort(key=lambda x: x[2])
# 最後の5項目を取る
print(lil_ary[-5:])
# [(1, 3, 68), (0, 2, 79), (1, 0, 82), (4, 2, 83), (0, 4, 97)]

あとはこれの、タプルのそれぞれの先頭2要素を取り出せば欲しかったインデックスです。
何をやっているのかはこちらの方がわかりやすいかも、と思っていますがこのやり方はメモリ効率などの観点でデメリットもあるので巨大なデータへの適用はお勧めしません。