macでダブルクリックでシェルスクリプトやPythonファイルを実行する

知っている人にとっては常識だったのかもしれませんが、タイトルの通りMacOSのパソコンで、シェルスクリプトファイルをダブルクリックで実行する方法を紹介します。

MacではWindowsのバッチファイル(.bat等)と違って、.sh とか .py ファイルを作ってもダブルクリックでは実行できないと最近まで勘違いしていました。

ただ、どうやら .command という拡張子でファイルを作って実行権限を付与しておくと、ダブルクリックで実行できるようです。

ちょっとデスクトップにサンプルディレクトリ作ってやってみます。
シェルスクリプトの中身は、文字列の表示(echo)、カレントディレクトリの表示、ファイルリストの表示、シェルの種類の表示、くらいにしておきましょかね。

$ mkdri sample_dir
$ cd sample_dir
$ vim test.command
# 以下の内容を記入して保存
echo "Hello world!"
pwd
ls
echo $SHELL

この時点でできてるファイルをダブルクリックしても、 「ファイル“test.command”は、適切なアクセス権限がないために実行できません。」 ってメッセージが表示されます。 chmod +x とか chmod 744 等して実行権限を付与します。

# 元の権限(見るだけ)
$ ls -l test.command
-rw-r--r--@ 1 yutaro  staff  39 12  4 00:22 test.command
# 所有者に実行権限付与
$ chmod u+x test.command
# 確認
$ ls -l test.command
-rwxr--r--@ 1 yutaro  staff  39 12  4 00:22 test.command

こうすると、このファイルをファインダーでダブルクリックするとターミナルが開いて以下の内容が表示されます。

/Users/yutaro/Desktop/sample_dir/test.command ; exit;
~ % /Users/yutaro/Desktop/sample_dir/test.command ; exit;
Hello world!
/Users/yutaro
# {色々散らかってるのでlsの結果は省略}
/bin/zsh

Saving session...
...copying shared history...
...saving history...truncating history files...
...completed.

[プロセスが完了しました]

zshで動きましたね。 exit; は勝手につけて動かしてくれるようです。
また、ユーザーのホームディレクトリで動いていることも確認できました。

ホームディレクトリで動くって点は注意が必要ですね。その.commandファイルが配置されているディレクトリで動いて欲しいことも多いと思います。ファイルが配置されているフォルダで動作させたい場合、スクリプトの先頭でcdして対応します。具体的には以下の1行をスクリプトの先頭に追記します。

cd "$(dirname "$0")"

次はスクリプトが動いた後、[プロセスが完了しました]の表示のまま、ターミナルが開きっぱなしになる点が気になるかもしれません。これはターミナルの設定を修正します。

iTerm2等を入れてる人もこれは標準ターミナルが動作してるので設定を変更する対象を間違えないようにしましょう。 ターミナルの設定のプロファイル> シェル のなかに、シェルの終了時: というオプションがあります。 これがウィンドウを閉じないになってると自動では閉じませんので、「シェルが正常に終了した場合は閉じる」にしておくと自動的に閉じることができます。

「ターミナル本体」の設定で挙動が変わってしまうので、.commandファイルごとに変えられないのが難点です。少々面倒ですが見逃せないエラーメッセージ等あるかもしれないので手動で閉じるようにした方がいいかもしれません。

さて、これでダブルクリックで動くシェルスクリプトファイルが作れましたが、同様にPythonのコードを動かすこともできます。 Pythonファイルと.commandファイルを個別に作成して、その.commandファイルの中でPythonファイルをキックしても良いのですが、.commandファイルの先頭に、シバンライン(#!で始まる実行するスクリプトを指定する文字列)を追加する方が1ファイルで済むのでシンプルです。 例えば次のように書きます。(ファイルの中身だけ書いています。chmod等は先ほどのファイルと同じように実行しておいてください)

#!/usr/bin/env python
for i in range(5):
    print(i)

(2行目以降はただのサンプルのPythonコードです。)

これで、拡張子を .py ではなく .command にして実行権限を付与しておけばダブルクリックで動くPythonファイルの完成です。

Pythonで100%積み上げ棒グラフを描く

個人的な好みの話ですが、僕は割合の可視化は円グラフより100%積み上げ棒グラフの方が好みです。前職時代はこの種の可視化はもっぱらTableauで描いていたのですが転職して使えなくなってしまったので、Pythonでサクッと描ける方法をメモしておきます。

最初、matplotlibのbarやbarhでbottomやleftを逐一指定してカテゴリごとに描いていく方法を紹介しようと思っていたのですが、pandas.DataFrameのメソッドを使った方が簡単だったのでそちらを先に紹介します。

データの準備

サンプルデータが必要なので適当にDataFrameを作ります。地域ごとに、どの製品が売れているか、みたいなイメージのデータです。

import pandas as pd


data = {
    '製品A': [20, 30, 50],
    '製品B': [60, 70, 40],
    '製品C': [20, 0, 10]
}
df = pd.DataFrame(data, index=["地域1", "地域2", "地域3"])

print(df)
"""
     製品A  製品B  製品C
地域1   20   60   20
地域2   30   70    0
地域3   50   40   10
"""

こちらを地域別に、売れている製品の内訳を可視化していきます。
これも個人的な好みの話ですが、棒グラフは縦向きではなく横向きにします。(ラベルが日本語だとその方が自然な結果になりやすいからです。)

シンプルにやるには、jupyter環境であれば以下のコードだけで目的のグラフが表示されます。

# データの正規化
df_normalized = df.div(df.sum(axis=1), axis=0)
# 横向きの積み上げ棒グラフの描画
df_normalized.plot(kind='barh', stacked=True)

少し脇道に逸れますが、みなさん、この行ごとに正規化するdf.div(df.sum(axis=1), axis=0) ってやり方ご存知でした?昔、「Pandasのデータを割合に変換する」って記事を書いたときは知らなかった方法でした。このときは転置して列ごとに正規化してもう一回転置するという手順を踏んでましたね。

記事の本題に戻ると、plotメソッドのstackedっていう引数をTrueにしておくと棒グラフを積み上げて表示してくれるため目的のグラフになります。
参考: pandas.DataFrame.plot.barh — pandas 2.1.3 documentation

ただ、これで出力すると、グラフの棒の並び順が上から順に地域3, 地域2, 地域1 となり、ちょっと嫌なのと、あとラベル等のカスタマイズも行えた方が良いと思うのでもう少し丁寧なコードも紹介しておきます。(こだわりなければさっきの2行で十分です。)

import matplotlib.pyplot as plt


# データの正規化
df_normalized = df.div(df.sum(axis=1), axis=0)
# 行の順序を逆にする
df_normalized = df_normalized.iloc[::-1]

# 横向きの積み上げ棒グラフの描画
fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)
df_normalized.plot(kind="barh", stacked=True, ax=ax)

# タイトルとラベルの設定(任意)
ax.set_title("地域別の販売製品割合")
ax.set_ylabel("地域")
ax.set_xlabel("割合")
plt.show()

出来上がりがこちら。

はい、サクッとできましたね。

ちなみに、pandasのplotメソッドを使わない場合は棒グラフの各色の部分の左端の位置を指定しながら順番に描くため、次のコードになります。

import numpy as np


# 積み上げ棒グラフを作成するための基準位置
left = np.zeros(len(df_normalized))

fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)


# 各カテゴリごとにバーを追加
for column in df_normalized.columns:
    ax.barh(
        df_normalized.index,
        df_normalized[column],
        left=left,
        label=column,
        height=0.5
    )
    left += df_normalized[column]

# タイトルとラベルの設定
ax.set_title("地域別の販売製品割合")
ax.set_ylabel('地域')
ax.set_xlabel('割合')

# 凡例の表示
ax.legend()

plt.show()

明らかに面倒なコードでしたね。pandasがいい感じに調整してくれていた、棒の太さ(横向きなので今回の例では縦幅)なども自分で調整する必要があります。
Pythonでコーディングしてるのであれば大抵の場合は元データはDataFrameになっているでしょうし、もしそうでなくても変換は容易なので、Pandasに頼った方を採用しましょう。

Lightsail(WordPress)の2023年11月時点のディレクトリ構造

前回の記事で書いた通り、このブログが稼働しているサーバーを新しいインスタンスに移行しました。その中で各作業をやっていてディレクトリ構成が昔と変わっているのが目についたので色々メモっておきます。

自分都合で申し訳ないのですが、今の構造自体の話よりも5年前の先代のインスタンスとの差分に着目して記録しています。自分の記事や手元に残したメモと対比するためです。

バージョンの違い

いつのバージョンと比較するのか明確にするために自分の新旧インスタンスの除法を載せておきます。このブログは2019年の1月から運用していますがサーバーを立てたのはその直前の2018年12月です。両インスタンスにSSHでログインして気づいたのですが、そもそもOSから違いますね。UbuntsだったのがDebianになってます。またアプリも「the Bitnami WordPress」が 「the WordPress packaged by Bitnami」に変わったようです。これは色々変更されていても納得です。

旧インスタンス

Welcome to Ubuntu 16.04.5 LTS (GNU/Linux 4.4.0-1128-aws x86_64)
       ___ _ _                   _
      | _ |_) |_ _ _  __ _ _ __ (_)
      | _ \ |  _| ' \/ _` | '  \| |
      |___/_|\__|_|_|\__,_|_|_|_|_|

  *** Welcome to the Bitnami WordPress 4.9.8-0 ***
  *** Documentation:  https://docs.bitnami.com/aws/apps/wordpress/ ***
  ***                 https://docs.bitnami.com/aws/ ***
  *** Bitnami Forums: https://community.bitnami.com/ ***

新インスタンス

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
       ___ _ _                   _
      | _ |_) |_ _ _  __ _ _ __ (_)
      | _ \ |  _| ' \/ _` | '  \| |
      |___/_|\__|_|_|\__,_|_|_|_|_|

  *** Welcome to the WordPress packaged by Bitnami 6.3.1-23        ***
  *** Documentation:  https://docs.bitnami.com/aws/apps/wordpress/ ***
  ***                 https://docs.bitnami.com/aws/                ***
  *** Bitnami Forums: https://github.com/bitnami/vms/              ***

WordPressのインストール先の変更

WordPressの利用のために使っているインスタンスですからこれが一番重要です。

旧インスタンスでは、Homeディレクトリにappsってディレクトリがあってその下にwordpressもありました。その配下にある、
/home/bitnami/apps/wordpress/htdocs
がドキュメントルートです。wp-config.php などのファイルもここにありました。

新環境では ~/apps はなくなり、wordpressは、/opt/bitnami の配下に移動しています。
/opt/bitnami/wordpress
がドキュメントルートで、wp-config.phpなどはこちらにも移動しています。

Google アドセンスのads.txtの置き場所もこちらへ変更です。

他のブログを見てると、/opt/bitnami/apps ってディレクトリに言及してる人もいるので、もしかしたら時代によっては /opt/bitnami の前に、そこが使われた頃もあったのかもしれません。

DB関係のディレクトリパスの変更

そもそもとして、インストールされているDBがMySQLからMariaDBに変わりました。

# 旧インスタンス
$ which mysql
/opt/bitnami/mysql/bin/mysql

# 新インスタンス
$ which mysql
/opt/bitnami/mariadb/bin/mysql

結果設定ファイルのmy.confの場所も、
/opt/bitnami/mysql/my.conf
から
/opt/bitnami/mariadb/conf/my.conf
に変わっています。

このmy.confの中を見ると、datadir って変数でDBのデータの実態ファイルの書くのディレクトリが指定されていますが、
/opt/bitnami/mysql/data
から
/bitnami/mariadb/data
へと変更されています。

/bitnami ってディレクトリ自体も新設されたものですね。

アップロードした画像等の配置場所

実はアップロードした画像なども先ほど書いた、/bitnami 配下に格納されています。データ関連の実態ファイルを一箇所にまとまったイメージでしょうか。

/opt/bitnami/wordpress で ls -la すると、
wp-content から /bitnami/wordpress/wp-content へシンボリックリンクが貼られていることがわかります。

実は、 wp-config.php も実態のファイルはここにあって、ドキュメントルートにあるのはシンボリックリンクです。

なんか、こう見るとads.txtも正しい置き場所はここで、ドキュメントルートにはシンボリックリンクで対応するべきだったのかなという気がしてきます。

apacheのディレクトリ名変更

これはめっちゃ細かい話ですが、/opt/bitnami/apache2 だったのが、2が取れて /opt/bitnami/apache になりました。ご丁寧にシンボリックリンクが貼ってあって旧パスでもたどり着けるようになっています。

$ ls -la | grep apache
drwxr-xr-x 16 root    root    4096 Oct 10 05:24 apache
lrwxrwxrwx  1 root    root       6 Oct 10 05:24 apache2 -> apache

この下に logs があってそこにアクセスログ等があるのは変わらずです。
/opt/bitnami/apache/logs
に access_log / error_log の名前で入ってます。

LightsailのWordPressを新しいインスタンスに移行する手順

更新遅延について

いつも月曜日更新ですが今週は更新が遅れました。といいうのも、このWordpressが稼働しているLightsailのインスタンスがメモリ不足になり、記事の一覧や新規作成ページが開けなくなったからです。
PHP Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 20480 bytes)
みたいなエラーがエラーログに出ていました。

また、前々からPHPのバージョンが古い(7.0.3.1)ことで管理画面で警告が出ておりいつか対応しないといけないという状況でもありました。

LightsailのwordpressはBitnamiというソフトで稼働しているのですが、実はこれはPHPのバージョンアップができません。PHPのバージョンを上げたければインスタンスを作り直さないといけないという罠があります。

以上の二つの理由で、PHPのバージョンを上げないといけないし、メモリも増やさないといけないしとなったので、Wordpressの移行作業を行いました。

やり方はいろいろあると思うのですが、今回は「All-in-One WP Migration」というプラグインを使いました。

失敗した方法と正しい順序

実は一回しくじったのでそれについて先に書きます。

ブログにアクセスできない時間を最小限にするために実は最初、次の手順でやろうとしていました。

  1. 新しいWordpressのインスタンスを立てる。
  2. 新しいインスタンスに記事やメディアのデータを移行する。
  3. IPアドレスやドメインを新しいインスタンスに移す。
  4. https化などの追加作業を行う。

こうすれば、切り替えた瞬間から訪問者の方が記事を読めると思ってやったのですが、All-in-One WP Migration のプラグインが気が利きすぎていて困ったことが起きました。

それは記事中のテキストや、リンク等に含まれるこのブログのURLが、移行先のインスタンスのURLに勝手に書き換えられてしまったことです。要するに、https://analytics-note.xyz/ って文字列があったら全部、 http://{IPアドレス}/ になってしまいました。

そして、IPアドレスやドメインを設定しても置換されたテキストは元に戻りませんでした。
これを全部治すのは面倒だったので、そのインスタンスは破棄してやり直しました。記事を読めない時間が発生するのは諦めて、次の手順で行い、今のところ正常に動いているようです。

  1. 新しいWordpressのインスタンスを立てる。
  2. IPアドレスやドメインを新しいインスタンスに移す。
  3. https化などの追加作業を行う。
  4. 新しいインスタンスに記事やメディアのデータを移行する。
  5. 残作業の実施。

それでは、細かく手順を説明していきますね。

旧インスタンス側の作業

データのエクスポート

移行するためにデータをローカルに取り出します。先述の通り「All-in-One WP Migration」ってプラグインを使いました。プラグインの一覧から有効化すると左部メニューに登場しますので、エクスポートを選択します。エクスポート先が選べるので、「ファイル」を選ぶとそのままダウンロードが始まります。僕のブログは画像とか少ないのですがそれでも250MBくらいになりました。ちなみに、オプションがいくつかあって僕は次の二つ選びました。これはあまり重要ではないと思います。

  • スパムコメントをエクスポートしない
  • 投稿リビジョンをエクスポートしない

リダイレクト処理の停止

これは、httpやIPアドレスでのアクセスをリダイレクトしてる人だけの作業なので必須ではありません。

旧環境からはドメインを剥奪するので今後はIPアドレス直打ちでしかアクセスできなくなります。僕は普通にドメインでアクセスされなかったらリダイレクトするように設定していたのでそれを戻します。要するに次の記事の逆の手順を行いました。

参考: httpのアクセスをhttpsにリダイレクトする

以上で旧環境での作業は終了です。

新環境作業

インスタンス作成

続いて新環境側の作業ですが、最初にやるのは当然ですが新環境の作成です。以下の記事を参考に新しいインスタンスを立てます。静的IPアドレスは旧環境で使ってたのを使い回すのでインスタンスだけ立てたら大丈夫です。

参考記事: Amazon Lightsail で WordPressサイトを作成する手順

今回はメモリ不足の解消を兼ねていたので、$5の、1GB/2vCPU/40GB SSD を選択しました。

ここでちょっとLightsailの宣伝なのですが、最近$3.5や$5のインスタンスでもvCPUが2個になってます。以前は1個だったので同じ値段で倍のCPUが使える計算になりますね。とはいえ、僕が不足したのはCPUではなくメモリだったので結局以前より高いプランに変えたのですが。これは、特に何か理由がない人も移行するメリットがありそうです。

静的IPの付け替え

失敗した方法のところで説明しましたが、インスタンス作ったらもう早速IPアドレスとそれに紐づいているドメインを新インスタンスに渡します。

インスタンスができたらSSH接続してパスワードを確認します。pemキーはデフォルトでは同じのが使えるのでIPアドレスだけ変えたら繋がります。

GUIで簡単に作業できます。

Lightsail の ネットワーキングにアクセスし、静的IPアドレスを選択。
管理からデタッチし、インスタンスへのアタッチでプルダウンから新しいWordpressを選んでアタッチ、と画面に沿って作業していけば完了です。

これでもう、URL叩いたら新しいWordpress(記事とか何もない初期画面)が開くようになります。

今回、PHPのバージョンアップも目的の一つだったので、SSHで繋いで $php –version してみておきましょう。無事に上がっていますね・

$ php --version
PHP 8.2.11 (cli) (built: Oct  6 2023 09:57:45) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.11, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.11, Copyright (c), by Zend Technologies

https化

Let’s Encryptを使ってhttps化します。

最近は昔と手順が変わっていて、ssh接続して以下のコマンドを叩いてプロンプトに従っていくだけでできるようです。ドメイン名とかメールアドレスを途中で聞かれます。

$ sudo /opt/bitnami/bncert-tool

パスワードの確認と初回ログイン

https化したらデータの移行作業へと入っていきます。wordpressの管理画面にログインしないといけないので初期パスワードを確認します。

SSH接続したホームディレクトリの中に、bitnami_application_password
ってファイルがあり、その中に書かれています。

これを使ってログインします。

データのインポート

All-in-One WP Migrationを使ってインポートするので、最初にやるのはこのプラグインの有効化です。ついでに最新化もしておきましょう。

左メニューにImportが追加されるのでそれを選びます。すると、実はこの時点ではアップロードできるファイルの上限が設定されているのでそれを確認します。おそらくインスタンスサイズによって異なります。

僕の場合は、 Maximum upload file size: 80 MB. と表示されていて、保存していたファイルがそのままだと上げられませんでした。

そこで、PHPの設定を変更してアップロードできるファイルサイズを一時的に増やします。

以下のファイルを開きます。
/opt/bitnami/php/etc/php.ini

その中に、post_max_size と、 upload_max_filesize って設定があるので、それぞれ80Mを512Mとかに書き換えます。(修正前にファイルはバックアップ取りましょう。)

書き換えたら、次のコマンドで再起動します。

$ sudo /opt/bitnami/ctlscript.sh restart

これでアップロードできるファイルサイズが上がるので、エクスポートしていたファイルをアップロードします。

PHP7からPHP8への移行だったので色々警告とか出ましたが仕方ないので全て認めて作業進めます。

この時点で、記事や画像等のメディア、テーマ、小テーマ、プラグイン情報などが移行されほぼほぼ元通り見れるサイトになります。

/opt/bitnami/php/etc/php.ini の中身は元に戻して、もう一回再起動しておきましょう。

再ログインとDB更新

データをインポートすると、wordpressのユーザー情報、要するにパスワードなども復元されるので一回セッションが切れてログアウトします。

次にログインするときは、旧環境のパスワードでログインするので間違えないようにしてください。

ただし、パスワードが戻るのはアプリ(wordpress)のユーザーだけで、MySQLコマンド等で接続するときに使う、DB自体のパスワードは新しい環境のパスワードそのままです。あくまでも書き換わるのはDBの中身だけってことですね。

wordpressの管理画面に再ログインすると、「データベースの更新が必要です」ってメッセージが出るので更新します。

これでほぼ移行完了です。

残作業

残りは細かい残作業やっていきます。

例えばこれとかやる必要があります。
WordPressの一般設定にあるURLをhttpsに変える
LightsailのWordPressにads.txtを設置する
– 不要になったインスタンスの削除

ads.txtの設置場所は
/opt/bitnami/wordpress
に変わってました。

また、旧インスタンスは即消すのではなくしばらく並行しておいておく方がお勧めです。ぼくもしばらくは放置しておこうと思っています。

この後も何か残作業見つかったら順次対応していこうと思います。

Google AdSenseでGDPR同意メッセージを作成する

このブログではGoogle AdSenseを導入していますが、少し前から 管理画面にログインすると、GDPR同意メッセージを作成するよう促すポップアップが表示されるようになっていました。表示されるのはこれです。

かなり面倒だな、もう欧州からのアクセスをブロックしたい、くらいに思って放置していたのですがようやく重い腰を上げて対応したのでそのメモを残しておきます。

どうやら上のポップアップから指示に従って進めていけば設定は完了するようです。念のためドキュメントを見ておきたいという場合は、以下のページが参考になります。
GDPR 同意メッセージについて – Google AdSense ヘルプ
GDPR メッセージを作成する – Google AdSense ヘルプ

自分はアドセンスを設定しているサイトがこのブログだけなので、上のポップアップから完了させてしまいました。

手順

最初にGDPR同意メッセージ作成の同意方法を選択します。上の画面の3つの選択肢(3番目は作成しない、なので実質選択肢は2個)から一つ選ぶのですが、僕は一番上の Google 認定のCMPを使用する方法を選択しました。(細かい違いは理解できてないのですが、おそらく一番上が推奨だと思ったので。)
これで確認を押した時点でポップアップは消えます。

すると、アドセンス上部の赤い枠の注意メッセージで以下の文言が表示されます。

2024 年 1 月 16 日より、欧州経済領域(EEA)または英国(UK)のユーザーに広告を配信するすべてのパブリッシャー様は、Google の認定を受けた同意管理プラットフォーム(CMP)をご使用いただくことが必要となります。欧州経済領域と英国で広告を配信する際には、Google 独自の同意管理ソリューションを含む Google 認定の CMP をご利用いただけます。Google の同意管理ソリューションに関心をお持ちの場合は、まず GDPR メッセージを設定してください。

GDPRメッセージを作成、って文がその下にあるのでそこを押します。

すると、次の4ステップでメッセージが作成できるという説明が出ます。

  1. サイトにプライバシー ポリシーの URL を追加する
  2. 含める同意オプションを選択します
  3. GDPR アカウント設定を確認します
  4. GDPR メッセージを公開します

「使ってみる」を押して進めます。

見慣れない画面に行くので、右側の画面からサイトの選択をします。(自分がGoogle Adsenseを設定しているサイトの一覧がみれると思います。僕はこのブログだけです。)

この時、プライバシーポリシーのURLも設定する必要があります。まだない人はこの機会に作りましょう。

言語は日本語にしました。

「同意しない」や「閉じる」のオプションはオフとしました。

この辺りのオプションはどう選択したらどのようにユーザーに表示されるのかプレビューがー随時見れるので確認しながらいじりましょう。

「スタイル」を選ぶと他にも細かな調整ができます。僕はこのブログにロゴを持ってないので、ヘッダーのロゴをオフにしました(デフォルトがオンだったので)。

ここまで設定したら右上の「公開」ボタンを押します。

今後、メッセージを修正したい場合は、
プライバシーとメッセージから GDPRを選ぶことで先ほどの設定画面に戻れるようです。

テスト方法

設定した内容をテストする方法も用意されています。

参考: プライバシーとメッセージについて – Google アド マネージャー ヘルプ

上記のヘルプページ内の、「サイトのメッセージをテストする手順とパラメータ」という折りたたみコンテンツの中にパラメーターがいくつか紹介されています。

それによると、 自分のブログのURLの最後に、?fc=alwaysshow をつけてアクセスすると、地域を考慮せずにメッセージが表示されるようです。

僕は無事に作成したGDPRメッセージが表示されることを確認できました。

InteractiveShell オブジェクトを利用してマジックコマンドを実行したり変数を操作したりする

いつも使っているJupyterに関する話です。Jupyterの中では、InteractiveShellというオブジェクトが使われていて、これを利用することでさまざまな高度な操作を行うことができます。

多くのことができるのですが、最近これを使って変数を操作する機会があったのでその辺を紹介していきます。

そもそも、InteractiveShell オブジェクトとは?という話ですが、これはIPython(Jupyter Labのバックエンドとして動作する対話的なPythonシェル)の中心的なクラスです。このオブジェクトを通じて、現在のセッションの状態を取得したり、マジックコマンドを実行したりすることができます。

このInteractiveShellはget_ipython() という関数を使うことでそのnotebook自身で取得し、アクセスすることができます。

マジックコマンドの動的な実行

このInteractiveShellオブジェクトを使うと例えば、マジックコマンドをプログラムで実行したりできます。

例えば、%pwd っていう現在のでディレクトリを取得するマジックコマンドがありますが、これが次のようにして実行できます。

ip = get_ipython()
ip.run_line_magic('pwd', '')

(pwdをメソッドとして実行するメリットはなかなか薄いので例が適切でなくてすみません。)

マジックコマンドをそのままマジックコマンドととして実行するのと比べると、結果を文字列として取得して以降の処理で使えるとか、実行するコマンドをプログラムで動的に変化させて実行させることができるといった利点があります。

もちろん、 run_cell_magic() って関数もあるのでセルマジックも実行できますよ。

変数の取得や設定

今回紹介するもう一つの使い方は変数の取得や追加です。名前空間の操作といった方がわかりやすいかもしれません。

InteractiveShellオブジェクトを使って、upyter Labのセッション内で定義されている変数を取得したり、新しい変数を設定したりすることができます。

変数の取得は user_ns 属性を使います。現在定義されている変数が全部辞書として入っています。デフォルトで入ってるJupyter/IPython固有の変数も多いので、自分が宣言した覚えのないものもあると思います。

例えば次のようにして使います。

x = 10
y = "Hello"

ip = get_ipython()
print(ip.user_ns['x'])
# 10
print(ip.user_ns['y'])
# "Hello"

変数の取得としてはあまり用途ないかもしれないですね。どちらかというと既に宣言されているかどうかのチェックとかで使えるかもしれません。

逆に、次のようにして新しい変数を定義することもできます。

ip = get_ipython()
ip.push({'z': "新しい変数"})

print(z)
# 新しい変数

この例だけ見てると、 z=”新しい変数” って直接書くのと同じやん、と思えますが、この方法の利点は変数名の方もプログラムで動的に指定できることです。

まぁ、 exec(“z=’新しい変数'”) ってすれば変数名を動的に変えられるので固有のメリットというわけではないのですが、execは本当にリスクの大きな手段なのでそれよりは安全に使えるという利点があると思います。

この変数の名の指定は、関数やマジックコマンドの中で使うと、例えばユーザーが指定した変数名に結果を格納するといった応用ができます。

最近、別の場所で書いているnote記事の中で自分が自作したマジックコマンドを紹介したのですが、そこで使っているクエリの結果を引数で指定した変数に格納するのにこのip.pushを使っています。

参考: Snowflakeに手軽にSQLを打てるJupyterマジックコマンドを作ってみた

ip.push を使わずに、
ip.user_ns[‘z’] = “新しい変数”
みたいに、user_nsオブジェクトに直接突っ込んでも同じことができますので合わせて覚えておいてください。

InteractiveShellオブジェクトでできることのごく一部ではありますが、今回の記事としては以上となります。

二つの区間の重複を判定する効率的な方法

はじめに

ちょっと重い記事が続いてたので今回は小ネタです。

二つの区間データ、要するに数値の区間であれば最小値と最大値のペア、時刻の区間であれば開始時間と終了時間のデータが二つあったときに、それらの区間に重複があるかどうかをサクッと判定するアルゴリズムを紹介します。昔どこかでみた覚えがあるのですが、久々に実装で必要になったときにちょっとだけ悩んだのでそのメモです。

結論

結果だけ知りたい人向けに先に結論だけ書いときます。

区間[s1, e1]と[s2, e2]に重複があるかどうかは次の2つの方法で判定できます。

方法1: $(s1 \leq e2) \land (s2 \leq e1)$ ならばその2区間には重複があります。

方法2: $\max(s1, s2) \leq \max(e1, e2)$ ならばその2区間には重複があります。

両方の方法のメリットデメリットですが、方法1の方が計算速度が速いです。大小比較2個と論理演算ですからmax関数呼び出すより有利ですね。ただ、方法2の方は、応用として、二つの区間に重複があった場合、$\max(e1, e2) – \max(s1, s2)$で重複区間の長さを算出できます。(値が負になったら重複無し。)

(max関数がそんな極端に遅いってこともなく、わざわざ時間測定しない限り体感することもない速度差ではあるのであくまでも参考にどうぞ。)

方法1の導出

これだけで終えてしまうと記事量としてあまりに少ないのと、結論だけ書いてるとすぐ忘れそうなので、真面目に導出を説明しておきます。まずは方法1の方からです。

まず、二つの区間の相対的な位置関係は次の6パターンが存在します。(説明の単純化のため。s1,s2,e1,e2は全て異なる値とします。)

  1. 区間1全体が区間2より小さい。つまり、 $s1 < e1 < s2 < e2$.
  2. 区間1全体が区間2より大きい。つまり、 $s2 < e2 < s1 < e1$.
  3. 区間1が区間2に内包される。つまり、$s2 < s1 < e1 < e2$.
  4. 区間1が区間2を内包する。つまり、$s1 < s2 < e2 < e1$.
  5. 区間1の後半と区間2の前半が重なる。つまり、$s1 < s2 < e1 < e2$.
  6. 区間1の前半と区間2の後半が重なる。つまり、$s2 < s1 < e2 < e1$.

上記の6パターンのうち、1,2 が区間に重複がなく、3,4,5,6は区間に重複があります。

ここで、「そうか!3,4,5,6のどれかを満たすことを確認すればいいんだ!」と判断して4パターンの論理式を書いてorで繋ぐ、としないことがコツです。

区間に重複がないパターンの方が2種類しかなくて判定が単純なんですよね。そして、区間の定義から$s1<e1$と$s2<e2$はわかってるので、あと確認するのはe1とs2, e2とs1の代償関係だけなんですよ。

ということで、 $(e1 < s2) \lor (e2 < s1)$ であれば二つの区間に重複はないと言えます。

これの否定を考えると、ドモルガンの法則から方法1としてあげた式が導かれます。

方法2の導出

続いて方法2の導出です。

これは、先ほど挙げた3,4,5,6の4パターンの不等式を眺めると見つかる法則性なのですが、左の2辺はs1,かs2で、右の2辺はe1, e2なんですよね。

これを素直に数式に落とすと、$\max(s1, s2) \leq \max(e1, e2)$ となります。等号が成立するのはただ1点だけが重複する場合。

そして、区間が重複する場合は、$\max(s1, s2)$は重複区間の開始点であり、$\max(e1, e2)$は重複区間の終了点なので、この2項の差を取ると重複部分の長さも得られます。

まとめ

そんなに難しい計算ではなく、覚えてさえればサクッと実装できる式ではありますが、重複のパターンって4種類あるよなぁと考え始めてしまうと意外に手間取ります。

結果を丸暗記しなくても、区間が重複しないパターンは2個しかなくてそれを否定したら簡単だってことを頭の片隅にでも置いといてもらえると幸いです。

ジニ不純度について

ここ数週間にわたってエントロピー関連の話が続きましたので、ついでみたいになるのですが決定木の評価関数として同じようによく使われるジニ不純度についても紹介しておきます。
ジニ係数(はじパタなど)や、ジニ指数(カステラ本など)といった呼び名もあるのですが、経済学で所得格差の指標に用いられるジニ係数と紛らわしくなるので、この記事ではジニ不純度と呼びます。

ジニ不純度の定義

ジニ不純度は、決定木やランダムフォレストなどの機械学習のアルゴリズムにおいて、ノードの純度を計測するための指標の一つとして用いられます。要するに、そのノードに分類される観測値のクラスの内訳が全部同じだとなると不純度は小さく、同じノードに複数のクラスの観測値が混在してたら不純度は高くなります。エントロピーと似た挙動ですね。

数式としては次のように定義されます。$n$クラスに分類する問題の場合、$i$番目のクラスのサンプル比率を$p_i$とすると、ジニ不純度は次のように定義されます。

$$I = 1 – \sum_{i=1}^n p_i^2.$$

例えば、3クラスの分類問題を考えると、あるノードの観測値が全部同じクラスだったとします。すると、$p_1 = 1, p_2=0, p_3 = 0$みたいになるのですが、この時$I=0$と、不純度が0になります。また、3クラスが均等に混ざっていた場合、$p_1 = p_2 = p_3 = 2/3$となり、時に不純度は$I = 2/3$となります。$n$クラスの分類問題の場合、この$1-1/n$が最大値です。

もう7年近く前になりますが、初めてこの定義式を見た時は、1から確率の平方和を引くことに対してちょっと違和感を感じました。ただ、これは計算を効率的にやるためにこの形にしているだけで、実際は次の形で定義される値と考えておくと納得しやすいです。

$$I = \sum_{i=1}^n p_i (1-p_i).$$

これ展開すると、$\sum_{i=1}^n p_i – p_i^2$となり、$\sum_{i=1}^n p_i =1$ですから、先に出した定義式と一致することがわかります。

ジニ不純度の解釈

それでは、$p_i(1-p_i)$とは何か、という話なのですが解釈が二つあります。

まず一つ目。ある観測値がクラスiであれば1、クラスi出なければ0というベルヌーイ試行を考えるとその分散がこの$p_i(1-p_i)$になります。これを各クラス分足し合わせたのがジニ不純度ですね。

クラスiである確率が0の場合と1の2種類の場合がまじりっけ無しで不純度0ということでとてもわかりやすいです。

もう一つは、そのノードにおける誤分類率です。あるノードにおける観測値を、それぞれ確率$p_i$で、クラス$i$である、とジャッジすると、それが本当はクラス$i$ではない確率が$(1-p_i)$ありますから、誤分類率は$\sum_{i=1}^n p_i (1-p_i)$となります。

あるノードの観測値のクラスが全部一緒なら誤分類は発生しませんし、$n$クラスが均等に含まれていたら誤分類率は最大になりますね。不純度の定義としてわかりやすいです。

エントロピーと比較した場合のメリット

決定木の分割の評価として使う場合、エントロピーとジニ不純度は少しだけ異なる振る舞いをし、一長一短あるのですが、ジニ不純度には計算効率の面で優位性があるとも聞きます。というのも、$p\log{p}$より$p^2$のほうが計算が簡単なんですよね。scikit-learnがginiの方をデフォルトにしているのはこれが理由じゃないかなと思ってます。(個人的な予想ですが。)

ただ、実際に実験してみると計算速度はそこまで大差はないです(np.log2の実装は優秀)。なんなら自分で書いて試したらエントロピーの方が速かったりもします。とはいえ、$p\log{p}$の計算はちゃんとやるなら、$p=0$ではないことをチェックして場合分けを入れたりしないといけないのでそこまできちんと作ったらジニ不純度の方が速くなりそうです。

Pythonでの実装

数式面の説明が続きましたが、最後に実装の話です。実は主要ライブラリではジニ不純度を計算する便利な関数等は提供されていません。まぁ、簡単なので自分で書けば良いのですが。

scikit-learnのソースコードを見ると、ここで実装されて内部で使われていますね。

確率$p$のリストが得られたら、全部二乗して足して1から引けば良いです。
$1/6+1/3+1/2=1$なので、確率のリストは$[1/6, 1/3, 1/2]$としてやってみます。

import numpy as np


p_list = np.array([1/6, 1/3, 1/2])
print(1-(p_list**2).sum())
# 0.6111111111111112

まぁ、確かにこれならわざわざライブラリ用意してもらわなくてもって感じですね。

割合ではなく要素数でデータがある場合は全体を合計で割って割合にすればよいだけですし。

以上で、ジニ不純度の説明を終えます。エントロピーと比べて一長一短あるのでお好みの方を使ってみてください。

Pythonによる各種エントロピーや相互情報量の計算

エントロピーや相互情報量の記事が続いていますが、今回の記事で計算の実装方法を紹介して一旦区切りとします。

エントロピーも相互情報量も数式はそこまで難しくないので、numpyで定義通り計算しても良いのですが、エントロピー関係はSciPyに、相互情報量はscikit-learnに用意されているので今回の記事ではそれを使っていきます。

計算対象のデータは、[“a1”, “a2”, “a1”, “a1”, “a2”] みたいにローデータの一覧で保有している場合もあれば、”a1″が3個で”a2″が2個のようにカウント済みのものがある場合もあると思うのでそれぞれ説明していきます。

エントロピーの計算

まず一番基本的なエントロピーの計算からです。これは、scipy.stats.entropy メソッドを使います。
参考: scipy.stats.entropy — SciPy v1.11.3 Manual

基本的な引数はpkなので、確率の一覧を渡すのが想定されていますが、和が1でないなら1になるように正規化してくれるのでサンプルがある場合は個数を渡しても大丈夫です。また、base引数で対数関数の底を指定でき、デフォルトが$e$なので、情報理論で使う場合は$2$を指定しましょう。

やってみます。

import numpy as np  # データ作りに利用
import pandas as pd  # データ作りに利用
from scipy.stats import entropy


pk = np.array([1/2, 1/3, 1/6])  # 確率の一覧が得られた場合。
print(entropy(pk, base=2))
# 1.459147917027245

count_list = np.array([3, 2, 1])  # データの個数の場合
print(entropy(count_list, base=2))
# 1.4591479170272446

# カウント前のデータの一覧がある場合
data_sr = pd.Series(["a1", "a1", "a1", "a2", "a2", "a3"])
# value_counts()で数えあげたものをentropyに渡す
print(entropy(data_sr.value_counts(), base=2))
# 1.4591479170272446

結合エントロピーの計算

次は結合エントロピーです。エントロピーを単純に2次元に拡張したやつですね。(条件付きエントロピーではないので注意してください、

例えば次のような例を考えましょうか。

b1b2
a141
a223

結合エントロピーの場合はですね、元のカウントデータを2次元から1次元に並び替えて渡します。

matrix_data = np.array([[4, 1], [2, 3]])
print(matrix_data)
"""
[[4 1]
 [2 3]]
"""

# ravel か flattenで1次元化して計算する
print(entropy(matrix_data.ravel(), base=2))
# 1.8464393446710157

# 標本データがある場合
df = pd.DataFrame({
    "A": ["a1", "a1", "a1", "a1", "a1", "a2", "a2", "a2", "a2", "a2"],
    "B": ["b1", "b1", "b1", "b1", "b2", "b1", "b1", "b2", "b2", "b2"],
})

# カウントしたデータを使う
print(df.groupby(["A", "B"]).size())
"""
A   B
a1  b1    4
    b2    1
a2  b1    2
    b2    3
dtype: int64
"""

print(entropy(df.groupby(['A', 'B']).size(), base=2))
# 1.8464393446710157

条件付きエントロピー

次は条件付きエントロピーです。残念なことなのですが、メジャーなライブラリでは条件付きエントロピー専用の関数は提供されていません。

そこで、$H(A|B) = H(A, B) – H(B)$などのエントロピー間の関係式を使って計算することになります。相互情報量も含めて、$H(A|B) = H(A) – I(A; B)$などで計算してもいいのですが、SciPyで完結できるので最初の式のほうが良いでしょう。

先ほどの表データをサンプルとします。$H(B)$については、表データを縦に足し合わせてBだけのカウントデータを作って計算します。

data_B = matrix_data.sum(axis=0)
print(data_B)
# [6 4]

# H(B)の計算
entropy_B = entropy(data_B, base=2)
print(entropy_B)
# 0.9709505944546688

# H(A, B)の計算
joint_entropy = entropy(matrix_data.ravel(), base=2)
print(joint_entropy)
# 1.8464393446710157

# H(A|B) = H(A, B) - H(B)
conditional_entropy_A_given_B = joint_entropy - entropy_B
print(conditional_entropy_A_given_B)
# 0.8754887502163469

# 標本データがある場合
entropy_B = entropy(df["B"].value_counts(), base=2)
joint_entropy = entropy(df.groupby(["A", "B"]).size(), base=2)

conditional_entropy_A_given_B = joint_entropy - entropy_B
print(conditional_entropy_A_given_B)
# 0.8754887502163469

以上で、3種類のエントロピーが計算できました。

相互情報量

最後に相互情報量の計算方法です。

$I(A; B) =H(A)-H(A|B)$など複数の表現方法があるので、ここまでに計算してきた値から算出することもできます。

entropy_A = entropy(df["A"].value_counts(), base=2)
print(entropy(df["A"].value_counts(), base=2) - conditional_entropy_A_given_B)
# 0.12451124978365313

ただ、scikit-learnに専用のメソッドがあるのでこちらの使い方も見ておきましょう。
参考: sklearn.metrics.mutual_info_score — scikit-learn 0.18.2 documentation

引数は、mutual_info_score(labels_truelabels_predcontingency=None)
となっており、標本データを受け取るのが標準的な使い方で、その第一,第二引数をNoneにしてcontingency引数にカウントデータを渡すこともできます。(contingencyがNoneでない場合はこれが優先されて、先の二つの引数が無視されます。)

1点注意しないといけないのは、entropyと違って対数の底が指定できず、自然対数に固定されてしまうことです。底を$2$で考えたい場合は、$\ln{x}/\ln{2} = \log_2{x}$を使って変換が必要です。

from sklearn.metrics import mutual_info_score


# np.log(2)で割ることを忘れない
# カウントした表データがある場合
print(mutual_info_score(None, None, contingency=matrix_data)/np.log(2))
# 0.12451124978365345

# 標本データがある場合
print(mutual_info_score(df["A"], df["B"])/np.log(2))
# 0.12451124978365345

計算の都合上超軽微な誤差がありますが、それ以外は想定通りの値が得られていますね。

以上で、相互情報量も計算できるようになりました。

シャノンの補助定理とその応用

今回も引き続き情報量(エントロピー)関係の記事です。直近、エントロピーに関する記事を複数書いていますが、その中でいくつか証明をしていない性質があります。シャノンの補助定理という面白い定理を使うとそれらが証明できるので見ていきます。

情報量の話なのでこの記事では底を省略して書いた対数関数の底は$2$とします。$\log=log_2$です。一方で、自然対数も使うのでそれは$\ln=\log_e$と書きます。

早速、シャノンの補助定理を見ていきます。

定理

二つの確率事象系$A = \{a_k | k=1,\dots, n \}$と$B = \{b_k | k=1,\dots, n\}$の確率をそれぞれ$p_k$, $q_k$とします。つまり、
$\sum_{k=1}^n p_k = 1$, $\sum_{k=1}^n q_k = 1$です。この時次の関係が成り立ちます。

$$ – \sum_{k=1}^n p_k\log{p_k} \leq – \sum_{k=1}^n p_k\log{q_k}.$$

以上が、シャノンの補助定理の主張です。左辺は$A$のエントロピーで、右辺は対数関数の中身だけ別の確率事象系の確率になっていますね。

証明

定理を証明する前に、自然体数に関する$\ln{x} \leq x – 1$ $(x > 0)$という関係を使うのでこれを先に証明します。

正の実数$x$に対して、$f(x) = x -1 – \ln{x}$ とおきます。すると$f'(x) = 1 – \frac{1}{x}$となります。$f'(x)$の値を見ていくと、 $0 < x < 1$ の時$f'(x)<0$であり、$f'(1)=0$、そして$1 < x$の時$f'(x)>0$となるので、$x=1$で最小値をとり、その値は$f(1) = 1-1-\ln{1} = 0$です。

よって、$f(x) \leq 0$であり、$\ln{x} \leq x – 1$ が示されました。

ここから定理の証明に入ります。定理の左辺から右辺を引いたものを考えて、底の変換をし、先ほどの関係式を適用します。

$$ \begin{align}
– \sum_k p_k \log{p_k} + \sum_k p_k\log{q_k} &= \sum_k p_k \log{\frac{q_k}{p_k}}\\
&=\frac{1}{\ln{2}} \sum_k p_k \ln{\frac{q_k}{p_k}}\\
&\leq \frac{1}{\ln{2}} \sum_k p_k \left(\frac{q_k}{p_k} – 1 \right)\\
&= \frac{1}{\ln{2}} \sum_k (q_k – p_k)\\
&= \frac{1}{\ln{2}} \sum_k q_k – \frac{1}{\ln{2}} \sum_k p_k\\
&=0\end{align}.$$

よって、$ – \sum_{k=1}^n p_k\log{p_k} \leq – \sum_{k=1}^n p_k\log{q_k}$ が示されました。等号が成立するのは$p_k = q_k$の場合です。

この補助定理を使って、エントロピー関連の性質を見ていきます。

応用1. エントロピーの最大値

最初に見ていくのは、「平均情報量(エントロピー)の定義について」で見た、エントロピーの範囲、$0 \le H(a) \le \log{n}$ の最大値の方です。これは、$q_k = \frac{1}{n}$としてシャノンの補助定理を使うと証明できます。

$$H(a) = – \sum_k p_k\log{p_k} \leq – \sum_k p_k\log{\frac{1}{n}} = \log{n}$$

となります。

応用2. 相互情報量が0以上であること

次に証明するのは、「相互情報量について」で最後に書いた、相互情報量が非負の値を取るという話です。

これは、シャノンの補助定理をそのまま2次元の確率分布に拡張して使います。
$k = 1, \dots, n$, $l = 1, \dots, m$とし、$\sum_{k, l} p_{kl} = 1$、$\sum_{k, l} q_{kl} = 1$とすると、

$$- \sum_k \sum_l p_{kl} \log{p_{kl}} \leq – \sum_k \sum_l p_{kl} \log{q_{kl}}$$

ですから、

$$\sum_k \sum_l p_{kl} \log{\frac{p_{kl}}{q_{kl}}} \geq 0$$

となります。ここで、$p_{kl} = P(a_k, b_l)$とし、$q_{kl} = P(a_k) P(b_l)$とすると、

$$\sum_k \sum_l P(a_k, b_l) \log{\frac{P(a_k, b_l)}{P(a_k) P(b_l)}} \geq 0$$

となります。この左辺が相互情報量$I(A; B)$の定義そのものなのでこれが非負であることが示されました。

応用3. 条件付きエントロピーがエントロピーより小さいこと

3つ目はさっきの相互情報量の話から続けて導けるものです。「結合エントロピーと条件付きエントロピー」で、重要な性質として$H(A|B) \le H(A)$が成り立つと証明無しで言いました。

これはシンプルに、$I(A; B) = H(A) – H(A|B)$であり、$I(A; B) \geq 0$なので、移項するだけで、$$H(A) \geq H(A|B)$$ が言えます。

まとめ

この記事ではシャノンの補助定理とその応用をいくつか紹介しました。応用1,2,3で証明したことはそれぞれの記事で証明無しで紹介していたのを個人的にモヤモヤしていたので、それを解消できてよかったです。

エントロピーの最大値についても、相互情報量の非負性についても結構シンプルな主張なのですが、シャノンの補助定理無しで証明するのは結構難しいのでこの定理の偉大さを感じます。

ここしばらくエントロピーの理論面の記事が続いていますが、次あたりでPythonで実際に計算する方法を紹介してこのシリーズを完結させようかなと思っています。