MeCabの辞書に単語が重複した場合の挙動を調べてみた

以前、MeCabのユーザー辞書を作る方法を紹介しました。
参考: MeCabでユーザー辞書を作って単語を追加する

システム辞書に無い単語をユーザー辞書に登録して使えば、当然システム辞書の単語とユーザー辞書の単語の両方を使って形態素解析が行えるようになります。
この時にもし、システム辞書に登録済みの単語を改めてユーザー辞書に登録してしまったらどのような挙動になるのか気になったのでドキュメントを確認してみましたがそれらしい記載がありませんでした。(他サイトにユーザー辞書がシステム辞書を上書きするという情報もあったのですが、本当にそうなのか疑わしいとも思いました。)
そこで実験してみようと思ったのがこの記事です。

また、MeCabは起動時にシステム辞書は1つしか指定できませんが、ユーザー辞書は複数指定できます。その複数のユーザー辞書に登録したらどういう挙動になるのかも確認しました。
それとついでにですが、1個のユーザー辞書に同じ単語を複数回登録した場合(これはもうただの辞書作成時のミスでしかあり得ないのですが。)の事象も見ています。

え、システム辞書に登録されてる単語をユーザー辞書に登録することなんてある?と思われる方もいらっしゃると思いますが、これは普通にあります。気づかずに登録してしまった、という場合はもちろんですが、解析結果の誤りを修正するために生起コストの設定を変えたいというケースがあるのです。

例えば、IPA辞書そのままだと、「りんごジュース」の形態素解析結果は次のように誤ったものになります。

$ echo りんごジュース | mecab
りん	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

IPA辞書に「りんご」が登録されていないわけではありません。バッチリ含まれています。

# ビルド前のIPA辞書のファイルが含まれているディレクトリで実行
$ grep りんご * -r
Noun.csv:りんご,1285,1285,7277,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ

「りんご」自体の生起コストが高いこととか、「BOS」と「名詞,一般」の連接コストなどの諸々の事情によりこのような誤りが発生しています。これを是正する手段の一つが、「りんご」をもっと低い生起コストで登録することなのです。

とりあえず、生起コストを5000に落としてやってみます。下のコードでcatしてるようなテキストをファイルを作り、ユーザー辞書をコンパイルしてMeCabを動かしてみます。

# seedファイルの中身確認
$ cat apple1.csv
りんご,1285,1285,5000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
# コンパイル
$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u apple1.dic -f utf-8 -t utf-8 apple1.csv
reading apple1.csv ... 1
emitting double-array: 100% |###########################################|
done!
# 生成されたユーザー辞書を使って形態素解析(生起コストも表示)
$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -u apple1.dic
りんご	5000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

ユーザー辞書に登録した生起コスト5000のりんごを使って形態素解析されましたね。
この結果だけ見ると、システム辞書にある単語をユーザー辞書に登録したら情報が上書きされたように見えます。ただし、実際の動きはそうでは無いのです。

上書きされたように見えるだけで、システム辞書とユーザー辞書それぞれのりんごは別々の独立した単語として処理されていて、解には生起コストが低いユーザー辞書のりんごが採用されたというのが正確な動きになります。このことはN-Best解を表示すると確認できます。

$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -N3 -u apple1.dic
りんご	5000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りん	4705	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	6655	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	7277	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

3番目の解として、システム辞書の生起コスト7277のりんごもバッチリ登場していますね。上書きされて消えているわけでは無いのです。

つまりユーザー辞書に単語を登録しても、元のシステム辞書より高い生起コストを設定してたらそれは1番目の解としては使われないということです。apple2って名前で、生起コスト8000のりんごを登録してやってみます。

$ cat apple2.csv
りんご,1285,1285,8000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u apple2.dic -f utf-8 -t utf-8 apple2.csv
reading apple2.csv ... 1
emitting double-array: 100% |###########################################|
done!
$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -u apple2.dic
りん	4705	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	6655	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

システム辞書だけの場合と結果変わりませんでしたね。このことからも、ユーザー辞書の単語がシステム辞書の単語を上書きする説は誤りであることがわかります。

実は元々、他のサイトの記事で単語が上書きされる説を見かけて、ユーザー辞書を複数登録したら最後にどっちの単語が残るんだ?という疑問からこの検証を始めています。
しかし、「そもそも上書きしないで別の単語として扱われる」が結論であれば、同じ辞書に複数回単語登録したり、ユーザー辞書を複数使用してそれぞれに重複してた単語があったとしても、別の単語として扱われて生起コストで判定される、と予想が付きます。

一応、「りんご」が2回登録された辞書も作って、上で作った2辞書と合わせて3辞書で動かしてみましょう。

$ cat apple3.csv
りんご,1285,1285,6000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
りんご,1285,1285,4000,名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
$ /usr/local/Cellar/mecab/0.996/libexec/mecab/mecab-dict-index -d /usr/local/lib/mecab/dic/ipadic -u apple3.dic -f utf-8 -t utf-8 apple3.csv
reading apple3.csv ... 2
emitting double-array: 100% |###########################################|
done!
$ echo りんごジュース | mecab -F %m\\t%c\\t%H\\n -u apple1.dic,apple2.dic,apple3.dic -N6
りんご	4000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	5000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	6000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りん	4705	副詞,助詞類接続,*,*,*,*,りん,リン,リン
ご	6655	接頭詞,名詞接続,*,*,*,*,ご,ゴ,ゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	7277	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS
りんご	8000	名詞,一般,*,*,*,*,りんご,リンゴ,リンゴ
ジュース	3637	名詞,一般,*,*,*,*,ジュース,ジュース,ジュース
EOS

3つの辞書に登録した4つのりんごと、システム辞書に元々あったりんごが全部使われていますね。

NBest解に登場する順番もシンプルに生起コストの順番になっています。
ユーザー辞書で指定した順番に上書きされて最後の辞書の一番最後の単語しか残らないんじゃ無いか、みたいなことを懸念していましたが、そんなことは全くありませんでした。

UAとGTMが導入済みのブログにGA4も設定してみた

2020年10月に正式にリリースされた GA4 (Google アナリティクス 4 プロパティ) をこのブログでも使うことにしました。このブログでは元々前世代のUA (ユニバーサルアナリティクス)を導入しています。現時点ではGA4よりUAの方が機能が充実しているように感じていますが、今後はGoogleさんがGA4の方に力を入れて改善していき、そちらをスタンダードにするということなので、使い始めた次第です。
ただ、いきなり乗り換えるのではなく当分並行稼働させていきます。

作業の前にこのブログでの設定状況についてです。このブログでは、Wordpressのプラグインを使って、GTM(Googleタグマネージャー)を導入し、タグマネージャーを経由してUAのタグを発火させていました。
参考: Google タグマネージャー導入
また、当然Googleアナリティクスのアカウント等も元々保有しています。無い場合はそこから作る必要があります。
あくまでもこの記事は、すでにUA+GTMが稼働中のページにGA4を追加する手順です。

では進めていきましょう。

手順1. GA4のプロパティを作成する。
以下の手順で作成できます。

GA4ではUAとは別のプロパティを作成し使用する必要があります。
1. Googleアナリテクスにアクセスする。
2. 左ペイン一番下の「管理」をクリックする。
3. プロパティ のところにある、 + プロパティを作成 をクリックする。
4. プロパティの設定をする。
– プロパティ名に自分がわかりやすい名前を入力する。(僕は「分析ノートGA4」にしました。)
– レポートのタイムゾーンは日本を選択
– 通貨に日本円を選択
5. 次へをクリック
6. ビジネスの概要設定画面が出てくるのでサイトの特性に合わせて適切なものを選びます。
– 悩んだのですが、業種はコンピュータ、電気製品にしました。
– 一人で更新しているので、ビジネスの規模は小規模-従業員数1〜10名にしています。
7. 利用目的を聞かれるので、該当するものを選ぶ。
– 自分は次の二つを選びました。
サイトまたはアプリでの顧客エンゲージメントを測定する
サイトまたはアプリの利便性を最適化する
8. 作成をクリックする

少しステップが多いですが、画面に従い順次行えば途中で迷うことはないと思います。

手順2. データストリームの設定
プロパティができたら続いてデータストリームを設定します。このブログはWeb版しか無い(アプリなど提供していない)のでWebのデータストリームを作成します。
上記のプロパティの作成から続けて行えますが、一度閉じてしまった場合は設定から開きましょう。
1. ウェブを選択する。
2. ウェブサイトのURLとストリーム名を入力します。URLはhttps://analytics-note.xyz ですが、 ストリーム名はどうするか悩みました。複数のストリームを同時に使う予定はなかったので、analytics-note としています。Webとアプリを両方分析する人はそれぞれ見分けられる名前が良いと思います。
3. ストリームを作成をクリックする。
4. 観測用のIDが生成されるのでメモしておきます。GTMで使います。
観測用のIDは G-{アルファベットと数字}の形式になっています。

以上で、GA側の設定は終わりです。あとはなんらかの方法で発行されたIDや、観測用のタグをブログの方に埋め込む必要があります。今回は導入済みのGTMを使いました。

手順3. GTMにGA4計測タグを追加

すでにGTMに作成済みのコンテナをそのまま使います。
1. GTM にアクセスする。
2. 既存のコンテナを選択する。
3. 左ペインでタグを選択し、新規をクリックする。
4. [タグの設定] をクリックして [GA4 設定] を選択する。
5. 先ほどの測定 ID「G-XXXXXXXXXX」を入力する。
6. トリガーをクリックする。
7. All Pagesを選択し、保存をクリックする。
8. デフォルトで、 Google アナリティクス GA4 設定 という名前が入ってたのでそのまま保存する。

これでタグが作成されたので、これを公開するための手順を続けていきます。
9. ワークスペースに戻ってプレピューをクリック。
10. Connect Tag Assistant to your site とメッセージが表示されたら、
https://analytics-note.xyz/ と対象サイトのURLを入力してConnectをクリックする。
11. そのブラウザでいくつかのページにアクセスすると、別のデバック用に開いていたブラウザのタブで開いていたページで発火したタグをみることができる。(昔のGTMは画面下部で確認していたので、この仕様が変わっていたようです。)
12. Google アナリティクス GA4 設定がFired(発火)になっているのを確認する。
13. ついでにWordpressの管理画面にもアクセスしてそこは発火しないことも確認する。
14. Tag Assistant の小さいウィンドウの Finish を押してプレビューを終了する。
16. 「公開」ボタンをクリックする。
17. バージョン名と説明を求められるので入力し、再度「公開」をクリックする。

以上で、GA4のが設定が完了し、データ収集が始まります。動作テストとして、リアルタイムビューを見てみるのがおすすめです。

追加で、最低限の設定として以下の設定を入れました。

データ保持期限を14ヶ月に伸ばす(デフォルトは2ヶ月)
こちらは、設定の、プロパティの データ設定 > データ保持 から設定できます。デフォルトの保持期間はかなり短いので伸ばしておいた方が良いでしょう。

また、Googleシグナルを有効にしました。
こちらも データ設定 > データ収集画面 から設定できます。

UAとGA4を並行してみていると、ユーザー数の集計値に差分が生まれていたり、なくなってしまった指標があったり、UAの方が用意されているレポートが多くて便利に感じたりと色々差があり、現時点ではまだUAの方が良いツールに感じることが多々あります。

ただ、Googleさんの方針として今後の開発はGA4の方に注力していくとのことですので、将来的に便利なツールになっていくことを期待しながら少しずつGA4に慣れていきたいと思います。

2022年のご挨拶と今年の方針

新年明けましておめでとうございます。本年もよろしくお願いします。

さて、今年のこのブログの更新方針について決めたのでまとめておきます。
昨年末の記事でも少し頭出ししていましたが、ブログに限らず今年の計画や目標について考え、今年1年はアウトプットよりもインプットを重視した年にしようと決めました。また、その内容もデータサイエンス関連に限らず幅広く吸収していく年にしたいです。

アウトプットの時間は減らしたいのとインプット内容にこのブログ記事につながるようなテーマの物が減るということで、このブログの更新ペースは落とします。昨年の半分くらいにして週1回更新、年間50記事程度を目標にゆっくりやっていこうと思います。もし書きたいことがありすぎて困るようなことになったらまたその時にペースを見直すかもしれませんが。

僕はもともと読書が好きで色々なジャンルの本を幅広く読んでいました。その後、2017年に転職してデータサイエンティストになってからこの5年ほどの期間、まずは仕事で使うデータ分析のスキルを優先しようということで読む本がほとんど広い意味でのデータサイエンス関連や、ドメイン知識としての人材業界関連の本ばかりになっていました。特にそれが不満というわけでもなく、どんどん新しい知識が身に付き、できることが増えていくことにやりがいも感じていました。この分野は本当に学ぶことが多く、この先も興味が尽きることはなさそうです。ただその一方で、趣味に関する本とか書店でたまたま見かけて興味を持った本とか話題のベストセラー等々の他の読みたい本を読むのが完全に後回しになってきたのも事実です。

今年もデータ分析の勉強は継続はしますし、今の時点で絶対読みたいと思ってる本はそこそこあるのですがが、それらを読むのは月に1〜2冊程度に抑えようと思ってます。そして浮いた時間はまた昔みたいに、仕事や実用性を無視して興味を持ったものを何でも読んでいく時間にします。

その他、流石に3年も運用しているとこのブログにも色々改善したい点あったり、内容が古くなってしまった記事などもあります。新規の記事を書く時間を減らした範囲内で、過去記事の見直しなどを含めたメンテナンスにも細々と着手しようと思います。例えば「プログラミング」っていう非常に雑なカテゴリに多くの記事が集中してしまっているのでこの辺の見直しもしたいです。

以上のような方針のためこのブログの更新は昨年に比べてゆっくりになりますが、本年もよろしくお願いいたします。

2021年のまとめ

2021年の最後の投稿になります。

本年も訪問者の皆様には大変お世話になりました。書いた記事が多くの方に読んでいただけたということはもちろんですが、土日祝日なども平日より少ないとはいえ多くのアクセスがあり、休日も技術的な調べ物をしている熱心な人たちがいると実感できることは自分が学習を続けていく上でも大きな励みになりました。

今年も1年間の振り返りをやっていきたいと思います。本年までの累積の記事数および、年間のアクセス数は次のようになりました。
– 累計記事数 514記事 (この記事含む。昨年時点 409記事)
– 訪問ユーザー数 200,661人 (昨年実績 146,674人)
– ページビュー 348,595回 (昨年実績 258,698回)

年間100記事更新の目標を無事に達成でき、それに伴って訪問者の数も増えているので達成感を感じています。

とはいえ、多くのpvを集めているのは古い記事が多く、今年特に力を入れて書いたMeCabのアルゴリズムの話や、AWSのAI関連サービスの話、トレジャーデータの小ネタなどはあまり読まれていないようです。テーマ選びなのか僕の文章力なのか、なんらかの課題はあるように感じています。

さて、恒例のよく読まれた記事ランキングを見ていきましょう。
今回は2021年1年間でのPV数によるランキングです。

  1. dailymall コックシャツ シェフコート コックコート 調理服 シェフ制服 制服 男女兼用 全5サイズ3色 - ブラック, 2XL (昨年1位)
  2. ネットワークグラフの中心性 (New)
  3. Pythonで連続した日付のリストを作る (New)
  4. pyenvで作成した環境を消す (New)
  5. TensorflowやKerasでJupyterカーネルが落ちるようになってしまった場合の対応 (New)
  6. numpyのpercentile関数の仕様を確認する (昨年4位)
  7. INSERT文でWITH句を使う (昨年7位)
  8. matplotlibでグラフ枠から見た指定の位置にテキストを挿入する (New)
  9. Stance メンズ アイコン3パック クルー (昨年3位)
  10. Pythonで多変量正規分布に従う乱数を生成する (昨年10位)

Googleアナリティクスで確認した時、1位と10位が昨年と同じなので今年もあまり変わり映えしないなという印象を持っていました。しかし、改めて昨年のランキングと比較してみると昨年ランクインしなかった記事が5記事も入っており意外と顔ぶれ変わってましたね。

このブログもこれで開設から丸三年になります。流石にネタ切れを感じる日もあるので来年の運用をどうしようかと考えています。(とはいえ、ブログネタのストックは今時点で40個程度はあるので本当の意味ではネタ切れしてないのですが、書きたいけどなかなか筆が進まないものやタイミングを逃した感があるのも多く難しいところです。)

来年も技術的なスキルアップを目指した学習はもちろん続けていきますし、仕事の中での疑問や課題感からネタが出てくることもあると思うので、ブログの更新自体は続けていきます。ただ、技術関連以外のインプットにももっと力を入れていきたいですし、休日の時間を今以上に読書や講座受講などに使いたいので、更新頻度は見直したほうがいいかもとは思っています。

この年末年始で来年をどう過ごすかを考えて、その中でブログの運用方針も決めたいと思います。

それではみなさん、今年も1年間ありがとうございました。良いお年を。

pipでライブラリをインストールする前に依存ライブラリを確認する

僕はAnacondaで環境を構築してcondaで運用しているのですが、どうしてもcondaでは入れられないライブラリがある時など、やむを得ずpipを使うことがあります。その場合、condaで入れられる限りの依存ライブラリを入れた後に必要最小限のライブラリをpipで入れるようにしているのですが、依存ライブラリの確認漏れ等があり、想定外のライブラリがpipで入ってしまうことがありました。(この運用もそろそろ限界を感じていて、次に環境を作り直す機会があったらpipで統一したいと思っています。)

問題の一つはpipでインストールする前に依存ライブラリを調べる方法が分かりにくかったことだと思っていたのですが、ようやく事前に調べかたがわかったのでそれを紹介します。

どうやら、PyPI の特定のURLでアクセスできるJSONファイルに、必要な情報が載っているようです。ここに書いてありました。
参考: PyPIJSON – Python Wiki

バージョンを指定しない場合は、
https://pypi.python.org/pypi/<package_name>/json
バージョンを指定する場合は、
https://pypi.python.org/pypi/<package_name>/<version>/json
というURLにアクセスすると、そのパッケージ(ライブラリ)の情報が取得できます。

試しに jupyter notebook (pip install notebook でインストールするので、ライブラリ名はnotebook)の情報ページである
https://pypi.python.org/pypi/notebook/json
にアクセスしていただくと分かりますが、かなりでかいJSONが得られます。

ここからテキストエディターで必要な情報を得るのは骨が折れるので、Python使って欲しい情報を探しましょう。

偶然見つけたのですが、 pprint というメソッドのドキュメントでの使用例がこのJSONの表示だったりします。そこでは urllibを使っていますがこれは若干使いにくいので僕はrequestsを使います。
参考: requestsを使って、Webサイトのソースコードを取得する

では、試しに notebook の 情報をとってみましょう。

import requests
package_name = "notebook"
url = f"https://pypi.org/pypi/{package_name}/json"
json = requests.get(url).json()
# このJSONはかなりでかい
print(len(str(json)))
# 113699
# JSONのkeys。 この中の info が必要な情報を含んでいる。
print(json.keys())
# dict_keys(['info', 'last_serial', 'releases', 'urls', 'vulnerabilities'])
# infoの下に、多くの情報がある。
print(json["info"].keys())
"""
dict_keys(['author', 'author_email', 'bugtrack_url', 'classifiers',
        'description', 'description_content_type', 'docs_url', 'download_url',
        'downloads', 'home_page', 'keywords', 'license', 'maintainer',
        'maintainer_email', 'name', 'package_url', 'platform', 'project_url',
        'project_urls', 'release_url', 'requires_dist', 'requires_python',
        'summary', 'version', 'yanked', 'yanked_reason'])
"""
# requires_dist が依存ライブラリの情報。リスト形式なので、順番に表示する
for requires_dist_text in json["info"]["requires_dist"]:
    print(requires_dist_text)
"""
jinja2
tornado (>=6.1)
pyzmq (>=17)
argon2-cffi
ipython-genutils
traitlets (>=4.2.1)
jupyter-core (>=4.6.1)
jupyter-client (>=5.3.4)
nbformat
nbconvert
nest-asyncio (>=1.5)
ipykernel
Send2Trash (>=1.8.0)
terminado (>=0.8.3)
prometheus-client
sphinx ; extra == 'docs'
nbsphinx ; extra == 'docs'
sphinxcontrib-github-alt ; extra == 'docs'
sphinx-rtd-theme ; extra == 'docs'
myst-parser ; extra == 'docs'
json-logging ; extra == 'json-logging'
pytest ; extra == 'test'
coverage ; extra == 'test'
requests ; extra == 'test'
nbval ; extra == 'test'
selenium ; extra == 'test'
pytest-cov ; extra == 'test'
requests-unixsocket ; (sys_platform != "win32") and extra == 'test'
"""
# requires_python で Pythonのバージョンの指定も見れる
print(json["info"]["requires_python"])
# >=3.6

extra がついているのはオプション付きでインストールする時に必要になる物なので、基本的に、次のライブラリが必要であることがわかりますね。
jinja2
tornado (>=6.1)
pyzmq (>=17)
argon2-cffi
ipython-genutils
traitlets (>=4.2.1)
jupyter-core (>=4.6.1)
jupyter-client (>=5.3.4)
nbformat
nbconvert
nest-asyncio (>=1.5)
ipykernel
Send2Trash (>=1.8.0)
terminado (>=0.8.3)
prometheus-client

ちょっとテストしてみましょう。 pyenv で新しい環境作って、notebook入れてみます。
(version 3.8.7と微妙に古いバージョン入れていますがこれは適当です。

# 新しい仮想環境を構築
$ pyenv install 3.8.7
# 環境切り替え
$ pyenv global 3.8.7
# ライブラリが何も入ってないことを確認(出力がない)
$ pip freeze
# notebook インストール
$ pip install notebook
# 依存ライブラリと共にインストールされたことを確認
$ pip freeze
appnope==0.1.2
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
attrs==21.2.0
backcall==0.2.0
bleach==4.1.0
cffi==1.15.0
debugpy==1.5.1
decorator==5.1.0
defusedxml==0.7.1
entrypoints==0.3
importlib-resources==5.4.0
ipykernel==6.6.0
ipython==7.30.1
ipython-genutils==0.2.0
jedi==0.18.1
Jinja2==3.0.3
jsonschema==4.3.2
jupyter-client==7.1.0
jupyter-core==4.9.1
jupyterlab-pygments==0.1.2
MarkupSafe==2.0.1
matplotlib-inline==0.1.3
mistune==0.8.4
nbclient==0.5.9
nbconvert==6.3.0
nbformat==5.1.3
nest-asyncio==1.5.4
notebook==6.4.6
packaging==21.3
pandocfilters==1.5.0
parso==0.8.3
pexpect==4.8.0
pickleshare==0.7.5
prometheus-client==0.12.0
prompt-toolkit==3.0.24
ptyprocess==0.7.0
pycparser==2.21
Pygments==2.10.0
pyparsing==3.0.6
pyrsistent==0.18.0
python-dateutil==2.8.2
pyzmq==22.3.0
Send2Trash==1.8.0
six==1.16.0
terminado==0.12.1
testpath==0.5.0
tornado==6.1
traitlets==5.1.1
wcwidth==0.2.5
webencodings==0.5.1
zipp==3.6.0

予想してたよりずっと多くのライブラリがインストールされましたね。どうやら依存ライブラリたちの依存ライブラリ、もちろんそれらの依存ライブラリも順次インストールされたようです。ただ、一つずつ確認したところ、JSONから取得した依存ライブラリは全て入ったことがわかります。

これは実験しておいてよかったです。必ずしも、JSONから得られたライブラリだけが入るわけではないことがわかりました。

もう一点補足しておくと、requires_dist には必ず値が入っているわけではありません。当然ですが依存ライブラリがないライブラリもあります。その場合は空配列になっているのかな、と思ったのですが、 null になるようですね。 NumPyなどがそうです。

package_name = "numpy"
url = f"https://pypi.org/pypi/{package_name}/json"
json = requests.get(url).json()
print(json["info"]["requires_dist"])
# None

以上で、pipインストール前にライブラリの依存ライブラリを調べられるようになりました。

ここで取得したJSONは他にも様々な情報を持っているようなので、それらも調べておこうと思います。

pandas.DataFrameのgroupby関数で計算した結果を各行に展開する

なんとなくドキュメントを眺めていたら、groupby().transform()っていう便利そうな関数を見つけたのでその紹介です。

DataFrameのgroupbyといえば、指定した列をキーとしてグループごとの合計や平均、分散、個数などの集計を行うことができる関数です。

通常は、集計したキーの数=グループの数の行数のDataFrameを戻り値として返してきます。

import pandas as pd
df = pd.DataFrame(
    {
        "category": ["A", "A", "A", "B", "B"],
        "amount": [100, 300, 100, 200, 200],
    }
)
print(df)
"""
  category amount
0        A    100
1        A    300
2        A    100
3        B    200
4        B    200
"""
print(df.groupby("category").sum())
"""
category
A            500
B            400
"""

ここで、この groupby して得られた集計値を、元のDataFrameの各業に展開したいことがあります。
そのような場合、僕はpd.mergeでデータフレームを結合するか、辞書形式に変換して結合することが多かったです。
例えば以下のようなコードになります。

# mergeで結合する場合
group_df = df.groupby("category").sum()
group_df.reset_index(inplace=True)
group_df.rename(columns={"amount": "category_amount"}, inplace=True)
print(pd.merge(df, group_df, on="category", how="left"))
"""
  category  amount  category_amount
0        A     100              500
1        A     300              500
2        A     100              500
3        B     200              400
4        B     200              400
"""
# 辞書を作ってマッピングする場合
group_df = df.groupby("category").sum()
sum_dict = group_df.to_dict()["amount"]
print(sum_dict)
# {'A': 500, 'B': 400}
df["category_amount"] = df["category"].apply(sum_dict.get)
print(df)
"""
  category  amount  category_amount
0        A     100              500
1        A     300              500
2        A     100              500
3        B     200              400
4        B     200              400
"""

書いてみるとこれらの手順を踏んでもそんなに複雑ではないのですが、やっぱり一発でできるともっと便利です。

そこで使えるのが、冒頭で紹介した、transformです。
参考: pandas.core.groupby.DataFrameGroupBy.transform

これは元のデータフレームと同じインデックスを持つデータフレームとして、GroupByの結果を返してくれます。ちょっとやってみます。

df = pd.DataFrame(
    {
        "category": ["A", "A", "B", "B", "B"],
        "amount": [100, 300, 100, 200, 200],
    }
)
# 元のDataFrameと同じ行数で、対応する行の"category"列の値が含まれるグループの合計を返す
print(df.groupby("category").transform("sum"))
"""
   amount
0     400
1     400
2     500
3     500
4     500
"""
# 元のDataFrameに合計値を付与したい場合は次のようにできる
df["category_amount"] = df.groupby("category").transform("sum")["amount"]
print(df)
"""
  category  amount  category_amount
0        A     100              400
1        A     300              400
2        B     100              500
3        B     200              500
4        B     200              500
"""

1行で済みましたね。

この新しく作った列を使えば、一定件数以下しか存在しないカテゴリの行を削除するとか、カテゴリごとにそれぞれの要素のカテゴリ内で占めてる割合を計算するとか、それぞれの要素のカテゴリごとの平均との差異を求めるとかそういった計算が非常に容易にできるようになります。

そしてさらに、このtransform とlambda関数を組み合わせて使うと、カテゴリの平均との差を一発で出す、といったこともできます。

df = pd.DataFrame(
    {
        "category": ["A", "A", "B", "B", "B"],
        "amount": [100, 300, 100, 200, 200],
    }
)
print(df.groupby("category").transform(lambda x: x-x.mean()))
"""
       amount
0 -100.000000
1  100.000000
2  -66.666667
3   33.333333
4   33.333333
"""

lambda 関数に渡されている x はそれぞれの行の値のように振る舞ってくれるにもかかわらず、同時に x.mean() でグループごとの平均を出すこともでき、その差分を元のDataFrameとインデックスを揃えて返してくれています。

これは使いこなせば相当便利なメソッドになりそうです。

MeCabで分かち書き済みの単語に対して品詞を判定する

MeCabで形態素解析してテキストを単語に分解するとき、分かち書きしたテキストと、品詞情報が得られます。その単語の出現頻度等を集計した後で、この単語はこの品詞、という情報を付与して絞り込み等をやりたくなったのでその方法をメモしておきます。

実は以前ワードクラウドを作った時に品詞別に色を塗るために似たようなコードを作っています。今回の記事はその改良版です。
参考: WordCloudの文字の色を明示的に指定する

この記事では次のようなコードを使いました。(参照した記事は先行するコードでMeCabのTaggerインスタンスを作ってる前提なのでその辺ちょっと補って書きます。)

import MeCab
tagger = MeCab.Tagger()
def get_pos(word):
    parsed_lines = tagger.parse(word).split("\n")[:-2]
    features = [l.split('\t')[1] for l in parsed_lines]
    pos = [f.split(',')[0] for f in features]
    pos1 = [f.split(',')[1] for f in features]
    # 名詞の場合は、 品詞細分類1まで返す
    if pos[0] == "名詞":
        return f"{pos[0]}-{pos1[0]}"
    # 名詞以外の場合は 品詞のみ返す
    else:
        return pos[0]

参照した記事で補足説明書いてますとおり、このコードは単語をもう一回MeCabにかけて品詞を取得しています。その時に万が一単語がさらに複数の形態素に分割されてしまった場合、1つ目の形態素の品詞を返すようになっています。

このコードを書いた時、単語がさらに分解されるってことは理論上はありうるけど、滅多にないだろう、と楽観的に考えていました。ところが、色々検証していると実はそんな例が山ほどあることがわかってきました。

例えば、「中国語」という単語がありますが、これ単体でMeCabに食わせると「中国」と「語」に分かれます。以下が実行例です。

# 形態素解析結果に「中国語」が出る例
$ echo "彼は中国語を話す" | mecab
彼	名詞,代名詞,一般,*,*,*,彼,カレ,カレ
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
中国語	名詞,一般,*,*,*,*,中国語,チュウゴクゴ,チューゴクゴ
を	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
話す	動詞,自立,*,*,五段・サ行,基本形,話す,ハナス,ハナス
EOS
# 「中国語」がさらに「中国」 と「語」に分かれる
$ echo "中国語" | mecab
中国	名詞,固有名詞,地域,国,*,*,中国,チュウゴク,チューゴク
語	名詞,接尾,一般,*,*,*,語,ゴ,ゴ
EOS

「中国語」が固有名詞、地域、国と判定されるとちょっと厄介ですね。

他にも、「サバサバ」は「サバ」「サバ」に割れます。

$ echo "ワタシってサバサバしてるから" | mecab
ワタシ	名詞,固有名詞,組織,*,*,*,*
って	助詞,格助詞,連語,*,*,*,って,ッテ,ッテ
サバサバ	名詞,サ変接続,*,*,*,*,サバサバ,サバサバ,サバサバ
し	動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
てる	動詞,非自立,*,*,一段,基本形,てる,テル,テル
から	助詞,接続助詞,*,*,*,*,から,カラ,カラ
EOS
$ echo "サバサバ" | mecab
サバ	名詞,一般,*,*,*,*,サバ,サバ,サバ
サバ	名詞,一般,*,*,*,*,サバ,サバ,サバ
EOS

他にも「ありえる」が「あり」「える」とか、「無責任」が「無」「責任」とか「ビュッフェ」が「ビュッ」「フェ」など、かなりの種類の単語が再度分解されます。

ということで、冒頭にあげた get_pos メソッドは思っていたよりもずっと誤判定しやすいということがわかってきました。

前置きが長くなってきましたが、このことを踏まえて、単語を再度分割することのないようにその単語としての品詞情報を取得できないかを考えました。

結局、制約付き解析機能を使って実現できそうだということがわかりました。
参考: MeCabの制約付き解析機能を試す

要するに、MeCabに渡された単語はそれで1単語だ、という制約を課せば良いわけです。

そのためには、-pオプション付きでTaggerを生成し、「{単語}{タブ}*(アスタリスク)」という形式のテキストに変換してTaggerでparseすれば大丈夫です。

Pythonのコードで書くと次のようになりますね。

import MeCab
tagger = MeCab.Tagger("-p")
def get_pos(word):
    # 制約付き解析の形態素断片形式にする
    p_token = f"{word}\t*"
    # 出力のEOS部分を捨てる
    parsed_line = tagger.parse(p_token).splitlines()[0]
    feature = parsed_line.split("\t")[1]
    # ,(カンマ)で区切り、品詞,品詞細分類1,品詞細分類2,品詞細分類3 の4項目残す
    pos_list = feature.split(",")[:4]
    # もう一度 ,(カンマ) で結合して返す
    return ",".join(pos_list)
# 利用例
print(get_pos("中国語"))
# 名詞,一般,*,*

上のコードは、品詞を再分類3まで取得するようにしましたが、最初の品詞だけ取得するとか、*(アスタリスク)の部分は省略するといった改修はお好みに合わせて容易にできると思います。

これで一旦今回の記事の目的は果たされました。

ただ、元の文中でその単語が登場したときの品詞が取得されているか、という観点で見るとこのコードも完璧ではありません。

表層系や原型が等しいが品詞が異なる単語が複数存在する場合、通常のMeCabの最小コスト法に則って品詞の一つが選ばれることになります。BOS/EOSへの連接コストとその品詞の単語の生起コストが考慮されて最小になるものが選ばれる感じですね。

分かち書き前のテキストで使われていたときの品詞が欲しいんだ、となると後からそれを付与するのは困難というより不可能なので、分かち書きした時点でちゃんと保存してどこかに取っておくようにしましょう。

あとおまけで、このコードを書いてる時に気づいたMeCabの制約付き解析機能の注意点を書いておきます。MeCabを制約付き解析モードで使っている時に、「表層\t素性パターン」”ではない”テキスト、つまり文断片と呼ばれている文字列を改行コード付けずに渡すとクラッシュするようです。
-p 付きで起動したときは、「表層\t素性パターン」形式の形態素断片か改行コードを必ず含むテキストで使うようにしましょう。

jupyter notebookでやると カーネルごとお亡くなりになりますので特に要注意です。

ちょっとコンソールでやってみますね。

$ python
>>> import MeCab
>>> tagger = MeCab.Tagger("-p")
>>> tagger.parse("中国語")
Segmentation fault: 11
# これでPythonが強制終了になる
$

改行コードつければ大丈夫であることは以下のようにして確認できます。

$ python
>>> import MeCab
>>> tagger = MeCab.Tagger("-p")
>>> tagger.parse("中国語\n")
'中国\t名詞,固有名詞,地域,国,*,*,中国,チュウゴク,チューゴク\n語\t名詞,接尾,一般,*,*,*,語,ゴ,ゴ\nEOS\n'

-p をつけてないときは別に改行コードなしのテキストも読み込んでくれるのでこれはちょっと意外でした。

制約付き解析(-p付き)でMeCabを使っている時に、「Segmentation fault: 11」が出たらこのことを思い出してください。

jupyter notebookのセルの出力をコードでクリアする

諸事情ありまして、jupyter notebookのセルの出力をクリアする方法を知りたくなったので調べました。
通常、jupyterではテキストを複数回にわたってprintしたり、matplotlibの図をいくつも出力するコードを1つのセルに書くと、出力したテキストなり図なりがダーっと続けて出てきます。
ちょっとこれを逐一クリアして新しいものだけ残すようにしたかったのです。
(こんなことする必要があることは滅多にないのですが。)

実は、クリアしたい対象がprintした1行以内のテキストの場合、それを実装する方法は過去に紹介したことがあります。それはprintメソッドのend引数を使ってprint後に改行コードを出力しないようにし、キャリッジリターン(“\r”)で出力位置を行頭に戻して空白で上書きしてしまうというものです。
これ使ってプログレスバーを作った記事が過去にありますね。
参考: printでお手軽プログレスバー

例えば、jupyterで次のコードを動かすと0~49まで数字がカウントアップします。
\r でカーソルを先頭にもどして、空白で埋めて、最後に次のprintのためにもう一回カーソルを先頭に戻しています。 end=”” はprint後に改行させない設定です。
sleep() は入れておかないと一瞬すぎて何も見えないのでウェイトとして入れています。

import time
for i in range(50):
    print("\r          \r", end="")
    print(i, end="")
    time.sleep(0.5)

ただ、さっきも書きましたがこの方法だと1行のテキストしか消せません。

複数行の出力だったらどうやって消すのかなと思って調べた結果見つかったのが、IPython モジュールにあった、 clear_output というメソッドです。
正確には、IPython.display.clear_output として実装されています。
ドキュメントはこちらです。
参考: Module: display — IPython 7.30.1 documentation

Clear the output of the current cell receiving output. とある通り、これが実行されるとそのステップが含まれたセルの出力だけを消してくれます。他のセルの出力は残してくれるので安心ですね。

wait (デフォルトはFalse)という便利な引数も持っています。これは、Falseにしておくと即座に出力を消すのに対して、Trueを渡すと、次の出力がくるのを待って消してくれます。連続して何かを出力するようなコードの場合、Trueにしておくと出力をスムーズに入れ替えるような動きになるのです。 Falseだと一瞬何も出力がない状態になるので次のセルとの間が詰まって 以降のセルがガクガク動きます。

以下のようにして、1秒ごとに現在時刻を表示する時計のような出力も出せます。

from IPython.display import clear_output
from datetime import datetime
import time
for i in range(10):
    print("現在時刻\n", datetime.now())
    clear_output(True)
    time.sleep(1)
"""
現在時刻
 2021-12-14 23:58:34.942141
上のような出力が1秒ごとに更新されて書き換えられる
"""

clear_outputはテキストだけではなく、図もクリアしてくれます。これを応用すると、パラパラ漫画のようにして手軽にアニメーションを作ることができます。

徐々にデータが増えて延びる折れ線グラフを描いてみたのが次のコードです。

import matplotlib.pyplot as plt
import numpy as np
# プロットする点を格納する配列
X = []
Y = []
for i in range(100):
    # 新しい点を追加する
    X.append(i)
    Y.append(np.random.randn())  # y座標には乱数入れる
    clear_output(True)  # それまでの出力をクリアする
    # グラフ作図
    fig = plt.figure(facecolor="w")  # 出力をクリアしたら改めてfigreオブジェクトが必要らしい
    ax = fig.add_subplot(111)
    ax.plot(X, Y)
    # グラフ表示
    plt.show()
    time.sleep(0.1)

このコードで jupyter 上にはアニメーションが表示できます。

実質的には clear_output(True) を差し込んでるだけなので、かなり手軽ですね。
ただ、これには一つ欠点もあって、jpyter上で簡易的に図を書いたり消したりしてアニメーションっぽく見せているだけなのでこのまま動画として保存することはできません。
(そのためこの記事にも結果の画像を貼っていません)

もし、gif形式などで保存したい場合は、少々面倒になるのですが、 ArtistAnimation などを使いましょう。過去の記事で取り上げています。
参考: matplotlibでgif動画生成

subprocessでパイプラインの実装

前回に続いてsubprocessの話です。予告していた通り、PythonでOSコマンドをパイプラインで繋いで実行する方法を紹介します。

まず前提ですが、subprocess.run にパイプラインを含むOSコマンドを渡してもそのままでは動きません。例えば実行中のプロセスから jupyter の文字を含む次のようなコマンドを考えます。

$ ps aux | grep jupyter
yutaro             762   0.0  0.8  4315736  67452 s000  S    11:55PM   0:03.71 {Pythonのパス} {pyenvのパス}/versions/anaconda3-2019.10/bin/jupyter-notebook
yutaro             910   0.0  0.0  4278648    712 s000  S+   12:04AM   0:00.00 grep jupyter

このコマンドをそのまま subprocess に渡しても動かないわけです。

import subprocess
cp = subprocess.run(
    ["ps", "aux", "|", "grep", "jupyter"],
    capture_output=True,
    text=True
)
# リターンコードが0ではない
print(cp.returncode)
# 1
# 標準出力は空っぽ
print(cp.stdout)
#
# 標準エラー出力にはエラーが出ている
print(cp.stderr)
"""
ps: illegal argument: |
usage: ps [-AaCcEefhjlMmrSTvwXx] [-O fmt | -o fmt] [-G gid[,gid...]]
          [-u]
          [-p pid[,pid...]] [-t tty[,tty...]] [-U user[,user...]]
       ps [-L]
"""

実は、パイプラインを含むコマンドを簡単に動かす方法はあります。それがshell引数にTrueを渡すことです。これは渡されたコマンドをシェルによって実行するオプションです。この場合、コマンドは空白で区切った配列ではなく一つの文字列で渡します。

cp = subprocess.run(
    "ps aux | grep jupyter",
    capture_output=True,
    text=True,
    shell=True
)
# リターンコードは0
print(cp.returncode)
# 0
# 標準出力に結果が入る
print(cp.stdout)
# 結果略。
# 標準エラー出力は空
print(cp.stderr)
# 

ただし、ドキュメントに「注釈 shell=True を使う前に セキュリティで考慮すべき点 を読んでください。」という注釈がついてるように、これはセキュリティ面で問題がある方法のようです。
参考: セキュリティで考慮すべき点
シェルインジェクションを避けるのはアプリ側の責任だって書いてありますね。この点気をつけて使いましょう。

さて、色々検証してみたのですが、 shell=True を使わなくてもパイプラインを実装する方法はあるようです。それは単純に標準入力を使う方法で、1個目のコマンドの標準出力を2個目のコマンドの標準入力に渡してあげます。

とりあえず、パイプラインではなく単一のコマンドで標準入力を使ってみましょう。macabコマンドに、いつもの「すもももももももものうち」を渡してみます。

runメソッドに標準入力を渡すには、 input という引数を使います。これで注意しないといけないのは、inputには”バイト列”でデータを渡す必要があることです。str型だとエラーになるので、encode() してから渡します。ただ、text=True も指定するときは逆にstrで渡さないといけないようですね。

text = "すもももももももものうち"  # 入力するテキスト
text_byte = text.encode()  # byte型にエンコード
cp = subprocess.run(
    "mecab",
    capture_output=True,
    input=text_byte  # 通常はbyte型で標準入力を渡す
)
# byte型でデータが返ってきているので、decode()して表示
print(cp.stdout.decode())
"""
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
"""
# text=True を指定するときは str型で標準入力を渡す
cp = subprocess.run(
    "mecab",
    capture_output=True,
    text=True,
    input=text  # text=True を指定するときは str型で標準入力を渡す
)
# str型で格納されているのでそのままprintできる
print(cp.stdout)
"""
結果は同じなので略
"""

さて、標準入力の渡し方がわかったら、あとは先行するコマンドの標準出力を次のコマンドの標準入力に渡すだけです。

最初の ps aux | grep jupyter でやってみましょう。

cp1 = subprocess.run(
    ["ps", "aux"],
    capture_output=True,
    text=True,
)
cp2 = subprocess.run(
    ["grep", "jupyter"],
    capture_output=True,
    text=True,
    input=cp1.stdout  # 一つ目のコマンドの標準出力を渡す
)
print(cp2.stdout)
"""
yutaro             762   0.0  0.8  4315736  67720 s000  S    11:55PM   0:05.04 {Pythonのパス} {pyenvのパス} /versions/anaconda3-2019.10/bin/jupyter-notebook
"""

この記事の先頭のコマンドの結果と微妙に異なりますね。 grep jupyter のプロセスが出てきません。これは、ps aux だけ先行して動かし、その結果をもとにgrepしているので、厳密にはシェルでパイプラインしたのとは異なるからそうなっているのでしょう。

ただ、通常の用途であればほぼ同じ結果が得られると思います。
どうしても差分が気になるのであれば shell=Trueの方の方法を使うことも検討が必要でしょうね。

サンプルとして選んだコマンドがイマイチだったので、厳密にいうと再現できてないサンプルを提示してしまったのですが、このようにして、PythonでOSコマンドのパイプラインが再現できます。

サンメディックUV 薬用デイプロテクト マイルド クリーム 顔・首用 25g SPF50+ PA++++ [医薬部外品]

このブログの過去記事でもすでに使ったことがあるのですが、改めてsubprocessの使い方をまとめておきます。
ドキュメントはこちら。
参考: subprocess — サブプロセス管理 — Python 3.10.0b2 ドキュメント

subprocessは os.system を置き換えるために作られた新し目のモジュールらしいので、僕も新しい方法としてこれを使っていたのですが、Python 3.5 から subprocess に run() というメソッドが実装され、僕が書いていた方法はいつの間にか古い方法になってしまっていたようです。ドキュメントを少し引用します。

サブプロセスを起動するために推奨される方法は、すべての用法を扱える run() 関数を使用することです。より高度な用法では下層の Popen インターフェースを直接使用することもできます。
run() 関数は Python 3.5 で追加されました; 過去のバージョンとの互換性の維持が必要な場合は、古い高水準 API 節をご覧ください。

subprocess — サブプロセス管理 — Python 3.10.0b2 ドキュメント

ちなみに、古い方法では、コマンドを実行したいだけなら call 、出力を得たかったら getoutput を使っていました。

import subprocess
# mkdir sample_dir を実行。 空白を含むコマンドは空白で区切って配列で渡す
subprocess.call(["mkdir", "sample_dir"])  # 成功すれば戻り値 として 0が帰ってくる
# 標準出力の結果が欲しい場合は getoutput メソッドを使う
output_str = subprocess.getoutput("ls -la")
print(output_str)

さて、本題の新しい方法の run の説明に入りましょう。
このメソッドはどうやら非常に多くの種類の引数をとるそうで、ドキュメントでも、「上記の引数は、もっともよく使われるものだけ示しており、後述の よく使われる引数 で説明されています」とある通り一部の引数しか掲載されていません。それでもこれだけ書かれています。

subprocess.run(
    args, *, stdin=None, input=None, stdout=None,
    stderr=None, capture_output=False, shell=False, cwd=None,
    timeout=None, check=False, encoding=None, errors=None,
    text=None, env=None, universal_newlines=None,
    **other_popen_kwargs)

基本的には、コマンドをスペースで区切って配列にし、callの時と同じように渡せば良いようです。touchでファイルを作ってみます。

subprocess.run(["touch", "sample_dir/sample1.txt"])
# CompletedProcess(args=['touch', 'sample_dir/sample1.txt'], returncode=0)

上のコード例は jupyter notebookで動かした時のイメージなので、勝手に最後のメソッドの戻り値がnotebookに表示されたのですが、これでわかる通り、 CompletedProcess というクラスのインスタンスを返してくれます。lsなどの標準出力を取りたい場合は、 capture_output を Trueにしておきます。

cp = subprocess.run(["ls", "-la", "sample_dir"])
print(cp.stdout)  # capture_output を指定しないと、stdoutに結果が入ってない
# None
cp = subprocess.run(["ls", "-la", "sample_dir"], capture_output=True)
print(type(cp.stdout))  # 結果はバイト型で入ってくる
# <class 'bytes'>
print(cp.stdout.decode())  # 文字列に変換したい場合はdecodeする
"""
total 0
drwxr-xr-x  3 {ユーザー名}  {グループ名}   96 12  8 00:41 .
drwxr-xr-x  7 {ユーザー名}  {グループ名}  224 12  8 00:52 ..
-rw-r--r--  1 {ユーザー名}  {グループ名}    0 12  8 00:41 sample1.txt
"""
cp = subprocess.run(["ls", "-la", "sample_dir"], capture_output=True, text=True)
print(type(cp.stdout))  # text=True も指定しておくと、str型で得られるのでdecodeがいらない。
# <class 'str'>
print(cp.stdout)
# (上のと同じなので) 出力略 

この、capture_output は 3.7 で追加されたそうで runメソッド本体より新しいオプションになります。 capture_output を使わない場合、 stdout と stderr にそれぞれ標準出力と標準エラー出力を指定することになります。ドキュメントでは PIPE とか STDOUT とかを指定するよう書かれていますがこれらは、 subprocess.PIPE, subprocess.STDOUT のことです。
両引数にそれぞれsubprocess.PIPE を指定すると、capture_output=Trueにしたのと同じ動きになります。stdout=subprocess.PIPE と stderr=subprocess.STDOUT の組み合わせで指定すると、標準出力と標準エラー出力を両方ともstdoutに格納してくれます。

ちょっと tarコマンドあたりでやってみます。出力先ファイルを – (ハイフン) にしておくと tar は結果のアーカイブをファイルを作らずに結果を標準出力に出力します。
また、 v をつけておくと標準エラー出力に処理したファイル情報を出すので subprocess の挙動確認にちょうど良さそうです。

# capture_output=True, と stdout=subprocess.PIPE, stderr=subprocess.PIPE は同じ動き
cp = subprocess.run(["tar", "cvf", "-", "sample_dir"],
                    stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
print(cp.stdout)
"""
{tarファイルの中身}
"""
print(cp.stderr)
"""
a sample_dir
a sample_dir/sample1.txt
"""
# stderr=subprocess.STDOUT とすると、標準エラー出力も標準出力に追記される
cp = subprocess.run(["tar", "cvf", "-", "sample_dir"],
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
# 標準エラー出力に出るはずだったアーカイブ対象情報もこちらに出る
print(cp.stdout)
"""
a sample_dir
a sample_dir/sample1.txt
{tarファイルの中身}
"""
# stderrは空
print(cp.stderr)
# None

最後に、コマンドがエラーになった時の処理です。
基本的には、 CompletedProcess が returncode という要素を持っているので、これで判定すれば良いと思います。 たとえば、 sample_dir というディレクトリは上のサンプルコードで作ったのが既にあるので、もう一度作ろうとすると失敗し、returncode が1になります。

cp = subprocess.run(["mkdir", "sample_dir"])
print(cp.returncode)
# 1

逆にいうと、コマンドが失敗してもPythonとしては特にエラーにならず、それ以降もコードがあるのであればプログラムは走り続けるということです。コマンドを実行したらreturncodeを確認して失敗してたら止めるような処理を明示的に作っておかないと予期せぬバグに繋がることもあるので気をつけましょう。

returncodeを確認するのではなく、コマンドが失敗したら例外を上げて欲しい、という場合は check=Trueを指定しておきましょう。

try:
    cp = subprocess.run(["mkdir", "sample_dir"], check=True)
except Exception as e:
    print(e)
    # Command '['mkdir', 'sample_dir']' returned non-zero exit status 1.

ちなみにですが、存在しないコマンドを渡すと check=True を指定していなくても例外が上がります。コマンドが存在しないのと、コマンドの結果がエラーになったのは明確に違う扱いになっているようですね。

try:
    cp = subprocess.run(["abcdefg", "aaaa"])
except Exception as e:
    print(e)
    # [Errno 2] No such file or directory: 'abcdefg': 'abcdefg'

これで簡単なコマンドであれば subprocess.run を使って実行できると思います。

あと、パイプラインを使うようなやり方について現在調べて検証しているので次の記事で紹介したいと思っています。

NOMINATE メンズ マウンテンバイクショーツ 軽量 自転車 サイクリング 3Dパッド入り MTBショーツ ルーズフィット品質管理の確実にすることを目的としているSpectra 革新的なエンジニアリング の完全なラジエーターは最大の冷却効率を実現します NIKE 最後に設計 または上回る リストバンド 商品の説明 エンジンを含むと伝送のパフォーマンスを満たす エンジンを含むすべてのコンポーネントに延長すると転送を実現しています 製造技術 ナイキ 革新的技術 Spectraプレミアムcu1722 Completeラジエーターforフォード ぴったりとしたフィット感保証 冷却効率が最大 Premium放熱器が元の機材放熱器に置き換えます;すべてのコンポーネントに延びている 適切なフィットに必要な編集されていません Premium放熱器を満たす 編集されていません適切なフィットを提供します または上回るパフォーマンスを実現するための元の機材放熱器に交換します スウッシュ 11460円 これらの単位は最後に完璧にフィットに設計保証が付いています レンジャー 製造技術と品質管理SPECTRA ブロンコ[カンペール] スニーカー DRIFT K200577 レディース本体重量:25g 装着してください 本体サイズ 犬用首輪 使用方法 DISUKI 幅X奥行X高さ S シリコン 青 :2.2×16×1.5cm 原産国:中華人民共和国 ナイロン 商品紹介 スウッシュ リストバンド 295円 指1本分大きめにサイズ調節してから 小型犬用 成分 ダイスキ シリコンカバー付きでバックル脱着時に指が痛くない NIKE リフアン反射カラー 原材料 ナイキ 商品の説明adidas(アディダス) スライドサンダル Adilette Comfort Slides レディースg ANSI規格品 フレーム:ポリカーボネート レンズ色:クリア 質量 その他 ツルの角度が調整できます NIKE スウッシュ :28 材質 3886921 ナイキ レンズ ミドリ安全 レンズ厚:2.3mm JIS ソフトな鼻パッドが左右に動き調節可能です 8147規格品 MP-821 商品の説明 T フレーム枠の角度を調整できます 5991円 レンズタイプ:両面ハードコート Z87.1 保護メガネ 二眼型 リストバンドサイクリンググローブ メンズ 夏用 手袋 薄型 5本指 グローブ 滑り止め UVカット 通気性よい スマホ対応 登山 バイク 運転 アウトドア スポーツ 男性用 自転車手袋 春夏 快適 お洒落 人気 プレゼント2021年卒業記念ギフト 幼稚園 ナイキ 5735円 モデル番号を入力してください 卒業ギフト 高品質のアクリル製 just FaCraft の文字が特徴です 幼稚園大学 高校卒業記念ギフト 高校卒業ギフトは Story 黒のギフトボックスと最高のウィッシュカード: 長持ちする画像 以下に適合します: . これが適合するか確認: My my 息子娘へのギフト 0.7インチです 商品の説明 x storory NIKE 再利用可能で丈夫 beginning Just 2021 美しいギフトボックスとグリーティングカードに入っています スウッシュ is 息子や娘へのギフト 友人や息子や娘への卒業ギフトとして便利です 大学高校卒業記念ギフト 4インチ の文字が2021年卒業証書のパターンで サイズ:彼女の記念日の卒業ギフトのサイズは4インチ 大学卒業記念品 彼女や彼への最高の卒業ギフト: 女の子向け リストバンド 高品質素材: 卒業証書付き卒業証書付き 彼女または彼への Beginning 幼稚園の高校大学のマスター医師の卒業生に最適なギフトです 彼女や彼への 男の子きざむ 名入れ ストームグラス サクラフレーム ギフト 贈り物冷感の感じ方には個人差があります エタノール 入ったときにはすぐに十分洗い流してください 目や鼻に刺激を感じることがあります Amazon.co.jp限定 メーカーより すぐに洗い流してください ボディウォーター x リストバンド 皮フ科医へご相談ください アイスオーシャンの香り 爽快シトラスの香り 吸い込まないよ 商品紹介 肌から15~20㎝程度離してスプレーしてください○持ち歩く際は 驚きのクール感が長続き イソステアレス-20 顔や皮フの敏感なところには使用しないでください NIKE 日焼け直後 無香料 さかさまでも使えるトリガーを採用 ギャツビークレイジークールボディウォーター無香料 ギャツビークレイジークールボディウォーターアイスシトラス 肌の弱い方 強い刺激を感じたときは使用を中止し 除毛直後 サイズ:170ml×2本パック メントールの冷感刺激に弱い方 原材料 ほてった体を瞬間冷却 ほてった体を瞬間冷却します 2 強い刺激を感じることがあります 手の届きにくい背中など広い範囲に使用できます 582円 傷等異常のあるときは使わないでください 刺激等の異常が出たら使用を中止し 超気持ちいい驚きのクール感が長続き 使用方法 クセになる気持ちよさ 少しずつ確かめてお使いください 原産国:日本 GATSBY 使用上の注意 アルコールに敏感な方 超過激なクール感 せいかん剤 ストッパーをOPENの位置から元の位置にもどしてください○残り少ない場合 クレイジークール ○レバーの下にあるストッパーを左右どちらかのOPEN位置にあわせ 上向きにご使用ください ギャツビーメンズ制汗剤 ギャツビークレイジークールボディウォーターアイスオーシャン 170ml メンチルエチルアミノシュウ酸 防腐剤フリー こんな時にオススメ 通勤前 メントール 運動の合間など PCAメンチル 仕事の後 ナイキ 商品特徴 メントキシプロパンジオール クール感が非常に高い商品です 水 クール感が飛び抜けてMAX ユーカリプトール スウッシュ エトキシジグリコール はじめは少量ずつ確かめてお使いください ※はじめてご使用になる方へ 香り特徴 当社内 香りを加えない無香料 周囲の人にも十分注意してお使いください ボルネオール お風呂上りや寝苦しい夜 なクレイジークール処方 目に入らないようご注意し メントール等揮発性成分が配合されているため 170ミリリットル 狭い空間での大量の使用は避けてください さかさまでも噴霧ができるトリガーを採用 内容量 ヒゲそり直後 ギャツビー 商品の説明 内容量:170ml×2本 汗をかくたびクール感がよみがえる シリーズ スキンタイプ:ノーマル はじめてご使用になる場合は 成分★ポスター丸めて★ [ 2バージョンセット発送 ] AB6IX - 3RD EP REPACKAGE [ SALUTE : A NEW HOPE ] 韓国盤リストバンド 特徴: 3×6フラットヘッドマシンネジ付き NL ナイキ Rustler モーターマウント 3x6フラットヘッドマシンネジで組み立て - 7460A 1677円 パッケージ内容はモーターマウント1つ Traxxas 部品番号 4X4 3.0mm : 工場パッケージ 1 仕様: スウッシュ 商品内容: NIKE 1とM3ロックナット 商品の説明安心してご利用頂くために検温を徹底しています のぼり旗 サイズ選べます(ショート60x150cm 右チチ)小さな部分を塗る場合は小さいブラシをご使用ください 絵の具が乾くまで待ち お好みに応じて NIKE あらゆるスキルレベルに最適 その後正しい色の絵の具を塗ってください 次回の使用時には 分かりやすい説明書付き 子供 LOWE9216 白や薄い色の絵の具を使うと その他のご質問がある場合は スウッシュ ペイントを終えるたびにブラシをきれいに洗ってください 数字 ご了承ください その場合は絵の具を重ね塗りして厚く覆うようにしてください 大人 高品質のアーティスト素材を使用 付属の絵の具は通常の使用においては十分です もし間違った色を塗ってしまった場合は リストバンド すぐに洗い流してください 蓋をしっかり閉めて乾燥を防ぐようにしてください キャンバス上の絵の具の数字と一致した番号で絵の具を塗ります 大人用 Frameless 日本語ではない場合があります 初心者 キャンバス上の数字が透けて見えることがあります 万が一絵の具が衣服に付着した場合は ブレンド不要 注意: ペイントバイナンバー 塗装の構造: 3785円 キャンバス上の数字が完全に覆われていない可能性があります 塗料を直接使用し ブラシ: 塗料: ありがとうございました 水を加えないでください キャンバス: 広い部分を塗る場合は大きなブラシを使い DIY油絵用のプロ仕様のキャンバスデザイン または数字の順番で絵のターンを決めることができます 16x20インチ ブラシ付き 商品の説明 美しい動物の絵 ナイキ 絵の具を使用しないときは 足りないよう無駄にしないでください 高品質で安全なグリーンのアクリル絵の具を使用 色を変えるときはブラシをきれいに洗ってください DIY絵画 ご連絡ください 注意:ご自身のニーズに合わせてフレームまたはフレームなしを購入してくださいWindows11搭載 第10世代i3-10110U DLGPC-DT1 ミニPC 小型PC メモリ8GB DDR4+NVMe SSD 256GB 高速WiFi 2G/5G Bluetooth4.0 USB3.1 DP HDMI アルミ合金ボディ 静音 2画面同時出力 4K対応 NAVY:ネイビー 星柄 カラフルな星柄 ナイキ 19.0cm 21cmサイズ感:普通 サイズ ■ポイント ヒールの高さ:約2.5cm※商品によって若干の個体差がでる場合がございます 18.0cm 商品の説明 カラー 21.0cm 男の子でも女の子でも使えるデザイン 20cm 素材 取り外し可能なカップインソール入り 小学生など小さいお子様におすすめの雨靴です 普段のサイズでお求めください 雨の日がくるのが待ち遠しくなっちゃう ネイビー 加工 生産国 16.0cm キッズ ホワイト 17.0cm 表地: です 合成素材 歩くたびにピカピカ光る 白 片足重量:約270g※17cmの場合足幅:2E相当 標準サイズ 紺 ネビー リストバンド アッパー全体の模様が可愛らしく 18cm 17cm 19cm アッパー:ポリ塩化ビニルソール:ポリ塩化ビニル歩く衝撃で靴底が光る 光る長靴 日本企画海外製 キッズ用長靴です mhs17 WHITE:ホワイト 動物 総柄の可愛いデザインで スウッシュ 靴底の中に埋め込まれている電池は取り替え不可です 長靴 木 20.0cm 1240円 歩くと靴底部分がピカピカ光る 光る 取り外し可能なカップインソール入りサイズ展開は NIKE 16cm レインブーツ