CUBE SUGAR CONTAINER

技術系のこと書きます。

Lima で仮想マシンのディスプレイを表示する

Lima 1 の仮想マシンは、デフォルトではディスプレイのない Headless モードで動作する。 とはいえ、作業の都合からディスプレイが欲しくなる場面もある。 そこで、今回は Lima の仮想マシンでディスプレイを表示する方法について書く。

使った環境は次のとおり。

$ sw_vers                                           
ProductName:        macOS
ProductVersion:     14.3.1
BuildVersion:       23D60
$ uname -srm                                   
Darwin 23.3.0 arm64
$ lima --version
limactl version 0.20.1

もくじ

下準備

下準備として Lima と wget をインストールしておく。

$ brew install lima wget

続いて、仮想マシンで利用する OS のイメージファイルをダウンロードしておく。 今回は Ubuntu 22.04 LTS を使った。

$ wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img

ディスプレイのある仮想マシンを作る

ダウンロードしたイメージファイルを使って仮想マシンを起動するために Lima の設定ファイルを用意する。 このとき video.display の項目を設定するのがポイント。 デフォルトでは none に設定されるため Headless になる。 ここを default に設定すると、システムで利用できるディスプレイの表示方法を自動で選んでくれる。

$ cat << 'EOF' > jammy.yaml
images:
- location: "jammy-server-cloudimg-arm64.img"
video:
  display: default
EOF

念のため limactl validate コマンドで、設定ファイルの記述を検証しておこう。

$ limactl validate jammy.yaml 
INFO[0000] "jammy.yaml": OK

あとは limactl start コマンドで仮想マシンを起動するだけ。

$ limactl start --tty=false jammy.yaml

すると、次のように仮想マシンのディスプレイが表示される。

Lima で起動した仮想マシンのディスプレイ

めでたしめでたし。

YAMAHA RTX830 で DS-Lite と PPPoE を併用して IKEv2 VPN を使う (ひかり電話なし)

今回は YAMAHA RTX830 で DS-Lite と PPPoE を併用しながら IKEv2 のリモートアクセス VPN を使えるようにする設定を紹介する。

リモートアクセス VPN を利用するためにはインターネット側を起点にした通信が必要になる。 しかし、DS-Lite の IPv4 通信は CGN が間に挟まっているので VPN のエンドポイントとして利用できない。 そこで、ISP の PPPoE で得られる動的な IPv4 アドレスを代わりに VPN のエンドポイントにする。 通常の IPv4 トラフィックは DS-Lite に流しながら、VPN のトラフィックだけをポリシーベースルーティングで PPPoE に向ける。

なお、動作に必要な最低限のコマンドだけを記述している。 実際に稼働させる場合には、セキュリティの観点からフィルタ (IPv4 / IPv6) を追加で設定するのが望ましい。 具体的な設定については、公式ドキュメントの設定例を参照のこと。 基本的には、以下の方針で設定することになるはず。

  • インターネットから LAN への通信を原則として遮断する静的フィルタ
  • LAN からインターネットへの通信を原則として通過させる動的フィルタ

また、インターフェイス的には LAN2 がインターネット側、LAN1 が自宅の LAN 側になる。 LAN のプライベート IP アドレスには 172.16.0.0/16 のサブネットを想定している。 ひかり電話の契約はないため IPv6 のアドレスはルータ広告で得られるプレフィックスから生成する。

使った環境は次のとおり。

# show environment 
RTX830 BootROM Ver. 1.01
RTX830 FlashROM Table Ver. 1.02
RTX830 Rev.15.02.30 (Wed Aug  9 16:15:25 2023)

もくじ

LAN2

まずはインターネット側に対応するインターフェイス LAN2 の設定から。 基本的には IPv6 でルータ広告とステートレス DHCPv6 を受け取る設定を入れる。

# LAN2 はキャリア回線として NTT NGN に接続している
ngn type lan2 ntt
# LAN2 で受信したプレフィックスを登録する
ipv6 prefix 1 ra-prefix@lan2::/64
# LAN2 で DHCPv6 を要求する (Inform-Request)
ipv6 lan2 dhcp service client ir=on

LAN1

続いて LAN 側に対応するインターフェイス LAN1 を設定する。

IPv4 に関してはプライベートアドレスを静的に付与してプロキシ ARP を有効にする。

# LAN1 に IPv4 アドレスを付与する
ip lan1 address 172.16.0.1/16
# IKEv2 のリモートアクセス VPN を使うので LAN1 はプロキシ ARP を有効にする
ip lan1 proxyarp on

IPv6 に関してはルータ広告のプレフィックスを元にアドレスを付与する。 また、LAN 側にいる端末が IPv6 で通信できるようにルータ広告と DHCPv6 サーバを有効にする

# LAN2 で受信したルータ広告のプレフィックスで LAN1 に IPv6 アドレスを付与する
ipv6 lan1 address ra-prefix@lan2::1/64
# LAN1 でルータ広告を有効にする (Other フラグ付き)
ipv6 lan1 rtadv send 1 o_flag=on
# LAN1 で DHCPv6 サーバを有効にする
ipv6 lan1 dhcp service server

PPPoE

続いては ISP の PPPoE を設定する。 いわゆる、一般的に NTT のフレッツを利用する際に使用する設定になる。 コマンドの pp は Peer-to-Peer の略で接続先情報を表すらしい。 <id><password> は ISP から提供された接続アカウントの内容に置換する。

# PP 1 番で PPPoE を設定する
pp select 1
 # 常時接続する
 pp always-on on
 # LAN2 で PPPoE を利用する
 pppoe use lan2
 # 自動で切断しない
 pppoe auto disconnect off
 # 認証方式に CHAP を利用する
 pp auth accept chap
 # 認証に使うアカウント
 pp auth myname <id> <password>
 # IPCP で IP アドレスを取得する
 ppp ipcp ipaddress on
 # IPCP で DNS サーバの IP アドレスを取得する
 ppp ipcp msext on
 # パケットを圧縮しない
 ppp ccp type none
 # NAT の設定に 1 番のデスクリプタを利用する
 ip pp nat descriptor 1
 # この PP 設定でネットボランチ DNS サービスを有効にする
 netvolante-dns use pp server=<num> auto
 # ネットボランチ DNS サービスのドメイン名を指定する
 netvolante-dns hostname host pp server=<num> <hostname>.<sub-domain>.netvolante.jp
 # PP 1 番を有効にする
 pp enable 1

上記で、PPPoE の IPCP で得られる動的 IP アドレスをネットボランチ DNS サービスに登録している点がポイントになる。 これによって VPN のエンドポイントが FQDN で解決できるようになる。

初回の設定をするときは pp select 1 した状態で、次のように <hostname> の部分だけを指定する。 この部分は、ドメイン名に沿った形でユーザが使いたいものを任意に指定する。

netvolante-dns use pp auto
netvolante-dns hostname host pp <hostname>

その上で、以下のコマンドを使って現在の IPv4 アドレスをネットボランチ DNS サービスに登録する。

netvolante-dns go pp 1

すると、設定が以下のような形に置き換わるはず。

netvolante-dns use pp server=<num> auto
netvolante-dns hostname host pp server=<num> <hostname>.<sub-domain>.netvolante.jp

置き換わったら設定を保存していく。

save

NAT

次に NAT (NAPT) を設定していく。

まずは PPPoE で Source NAT が使えるようにする。 先ほど設定した pp 1 では NAT のデスクリプタとして 1 番を指定していた。

# NAT のデスクリプタ 1 番で Source NAT を有効にする
nat descriptor type 1 masquerade

次に IPsec 関連のトラフィックを LAN1 に付与した IPv4 アドレスに Destination NAT する。 つまり、実際に VPN のエンドポイントになるのは LAN1 に付与された IPv4 アドレスということになる。

# ESP パケット
nat descriptor masquerade static 1 1 172.16.0.1 esp
# IKE パケット
nat descriptor masquerade static 1 2 172.16.0.1 udp 500
# NAT-T パケット
nat descriptor masquerade static 1 3 172.16.0.1 udp 4500

DS-Lite

次に DS-Lite を設定する。 といっても、単なる IP-IP トンネルに過ぎない。

# トンネル 1 番で DS-Lite を設定する
tunnel select 1
 # トンネル方式に IP-IP トンネリングを利用する
 tunnel encapsulation ipip
 # トンネルのエンドポイント (AFTR) を指定する
 tunnel endpoint name gw.transix.jp fqdn
 # トンネル 1 番を有効にする
 tunnel enable 1

ルーティング

続いて IPv4 のルーティングを設定する。 次のような方針で設定する。

  • メインの経路に DS-Lite のトンネルを利用する
  • バックアップの経路に PPPoE を利用する
  • IPsec 関連のトラフィックはポリシーベースルーティングで PPPoE に向ける

なお、YAMAHA のルータではポリシーベースルーティングをフィルタ型ルーティングと呼んでいる。

上記を反映したコマンドが次のとおり。 hide は接続が有効なときだけ経路として利用することを示す。 また weight はどれくらいの割合でトラフィックを流すかを指定する。 filter は、フィルタに合致したトラフィックだけをそのゲートウェイに流すことを意味する。

ip route default gateway tunnel 1 hide gateway pp 1 weight 0 gateway pp 1 filter 44051 44052 44053

つまり、DS-Lite が使える時はトラフィックをそちらに全て流し、使えない時は全て PPPoE に流す。 ただしフィルタに合致するトラフィックだけは常に PPPoE に流す、という意味になる。

コマンドの区切りを改行して分かりやすくしてみると次のようになる。 なお、あくまで例示のために改行しているだけで、この状態では有効なコマンドにならない。

ip route default
  gateway tunnel 1 hide
  gateway pp 1 weight 0
  gateway pp 1 filter 44051 44052 44053

上記で設定されているフィルタ型ルーティングに対応するフィルタは次のとおり。 LAN1 に付与した IPv4 アドレスが送信元になっている IPsec 関連のパケットがフィルタに合致する。 なお、フィルタ番号については任意の整数なので、取り違えにくいようなルールで自由に指定して構わない。

ip filter 44051 pass 172.16.0.1 * esp
ip filter 44052 pass 172.16.0.1 * udp 500 *
ip filter 44053 pass 172.16.0.1 * udp 4500 *

なお、IPv6 のルーティングに関してはルータ広告を元にデフォルトルートが自動で設定されるようだ。

DHCP サーバ

次に DHCP (DHCPv4) サーバを設定する。 といっても、サービスを有効にして払い出すアドレスレンジを指定するくらい。

# DHCP サーバを有効にする
dhcp service server
# リース情報を持たないクライアントからの DHCPREQUEST を無視する以外は RFC2131 の挙動に準拠する
dhcp server rfc2131 compliant except remain-silent
# DHCP サーバで払い出す IPv4 のアドレスレンジ
dhcp scope 1 172.16.1.1-172.16.1.254/16

DNS サーバ

DNS サーバに関してはプロキシのサービスを有効にする。 フルサービスリゾルバのアドレスは LAN2 から DHCPv6 で取得する。 PPPoE から取得する場合は dns server pp 1 を入れても良いけど、両方入っている場合は常に PPPoE が優先されるらしい。

# LAN1 で DNS サーバ (プロキシ) を有効にする
dns host lan1
# DNS サーバを LAN2 から DHCP で取得する
dns server dhcp lan2

IKEv2

そして IKEv2 のリモートアクセス VPN を設定する。 ipsec ike local name にはネットボランチ DNS サービスの FQDN を使っている。 ただ、もしかすると FQDN として解釈できる任意の文字列で良いかもしれない。 ipsec ike remote name には FQDN として解釈できる任意の文字列を入れる。 クライアント側で VPN の設定を作る際は localremote が入れ替わる点に注意する。 <psk> は IPsec の事前共有鍵なので、複雑で十分に長い文字列を指定する。

# トンネル 10 番で IKEv2 リモートアクセス VPN を設定する
tunnel select 10
 # トンネル方式に IPsec を利用する
 tunnel encapsulation ipsec
 # IPsec トンネル 1 番を設定する
 ipsec tunnel 1
  # IPsec ポリシー 1 番 / セキュリティゲートウェイ 1 番は ESP モードで動作する
  ipsec sa policy 1 1 esp
  # IKEv2 を利用する
  ipsec ike version 1 2
  # IKE Keepalive をログに出力しない
  ipsec ike keepalive log 1 off
  # RFC4306 方式で 10 秒毎に IKE Keepalive を送って 3 回失敗したら切断する
  ipsec ike keepalive use 1 on rfc4306 10 3
  # ローカル ID を指定する
  ipsec ike local name 1 <domain>.<sub-domain>.netvolante.jp fqdn
  # 事前共有鍵を指定する
  ipsec ike pre-shared-key 1 text <psk>
  # リモート ID を指定する
  ipsec ike remote name 1 <remote> fqdn
  # 配布するアドレスプールに 1 番を利用する
  ipsec ike mode-cfg address 1 1
  # IKE の鍵交換をルータ側から始動しない
  ipsec auto refresh 1 off
 # トンネル 10 番を有効にする
 tunnel enable 10

また、上記の設定で指定されている IKEv2 リモートアクセス VPN で配布するアドレスプールを定義する。

ipsec ike mode-cfg address pool 1 172.16.2.1-172.16.2.254/16

以上で RTX830 で DS-Lite と PPPoE を併用しながら IKEv2 のリモートアクセス VPN が使えるようになるはず。

クライアント側を設定する

あとはクライアント側に IKEv2 リモートアクセス VPN の設定をするだけ。 主な設定項目は前述のコンフィグから抜き出すと次のようになる。

  • エンドポイント
    • <domain>.<sub-domain>.netvolante.jp
  • リモート ID
    • <domain>.<sub-domain>.netvolante.jp
  • ローカル ID
    • <remote>
  • ユーザ認証
    • なし
  • 事前共有鍵
    • <psk>

つながらないときは syslog debug on にしたり、コンフィグを確認したり、パケットキャプチャしながら切り分けていく。

いじょう。

参考

www.rtpro.yamaha.co.jp

www.rtpro.yamaha.co.jp

www.rtpro.yamaha.co.jp

Raspberry Pi で AdGuard Home を動かす

AdGuard Home は、インターネット広告や端末のトラッキングなど 1 をブロックすることを目的とした OSS の DNS サーバ (プロキシ) のひとつ。 フィルタリングルールを入れた状態で DNS サーバとして利用することで、端末に依存しないフィルタリング 2 が可能になる。 今回は、そんな AdGuard Home を Raspberry Pi にインストールして試してみた。

使った環境は次のとおり。

$ cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
$ uname -srm
Linux 6.1.0-rpi7-rpi-v8 aarch64

もくじ

下準備

公式のインストールガイドには、AdGuard Home のバイナリをダウンロードして実行する方法が紹介されている。 ただ、それだとアップデートなどの手間がかかると思っていたところ、どうやら snap 3 のパッケージがあるらしい。

なので、まずは snap をインストールする。 また、動作確認用に dnsutils もインストールしておく。

$ sudo apt-get update
$ sudo apt-get -y install snapd dnsutils

snap のインストールガイドに従って再起動する。

$ sudo shutdown -r now

再起動したら、以下を実行して snap の状態を最新にする。

$ sudo snap install core

インストール

あとは snap コマンドで adguard-home をインストールする。

$ sudo snap install adguard-home

セットアップする

インストールが終わると TCP の 3000 番ポートを AdGuard Home が Listen し始める。 これは AdGuard Home の管理用 Web UI が、初期セットアップが終わるまで使うポート番号になる。

$ ss -tlnp | grep 3000
LISTEN 0      4096               *:3000            *:* 

この状態で、Raspberry Pi の IP アドレスの 3000 番ポートにブラウザでアクセスすれば管理用 Web UI が表示される。 Raspberry Pi をデスクトップ環境で使っているなら、単純にインストール済みのブラウザで http://localhost:3000/ を開けば良い。

初期設定のウィザードでは動作するネットワークインターフェイスやポート番号などを聞かれるので好みで変更しておく。 特にこだわりがなければ何もいじらなくても良いと思う。

初期設定が終わると DNS 用の 53 番ポートを Listen し始める。 また、管理用の Web UI が Listen するポートも、初期設定で指定したものに変化する。

$ sudo ss -tlnp | grep AdGuard | grep 53
LISTEN 0      4096               *:53              *:*    users:(("AdGuardHome",pid=679,fd=15))
$ sudo ss -ulnp | grep AdGuard | grep 53
UNCONN 0      0                                *:53               *:*    users:(("AdGuardHome",pid=679,fd=14))

デフォルトのフィルタリングルールとして AdGuard DNS filter というルールが入っている。 そのルールが適用されていることを確認する。

まずは www.google.com など、普段から利用するドメイン名が正引きできることを確認する。

$ dig +short www.google.com @127.0.0.1
172.217.175.4

次に、広告配信に使われているドメインが 0.0.0.0 に解決されることを確認する。 この動作は設定から変更が可能で NXDomain にすることなどから選べる。

$ dig +short doubleclick.net @127.0.0.1
0.0.0.0

なお、既存のサーバとして DMZ などで動作している場合にはオープンリゾルバにならないように注意する。

設定を調整する

あとは自分が意図した動作になるように設定を調整していく。 たとえば DNS のアップストリームサーバを変更したり、フィルタリングルールを追加・変更する。

AdGuard が公式で提供しているフィルタリングルールは以下に掲載されている。

adguard.com

一般的なユースケースで利用頻度が高そうなのは以下あたり。

  • Japanese filter
  • Mobile ads filter

フィルタを利用することで、自身が日頃から使っているサービスに何らかの問題が生じる可能性がある点には留意が必要になる。

一通りの設定が終わったら、あとは自身のネットワークで Raspberry Pi を DNS サーバとして利用する。 一般的なネットワークであれば、ルータの DHCP サーバで Raspberry Pi の IP アドレスを DNS サーバとして配布するように設定する。

参考文献

github.com

https://snapcraft.io/docs/installing-snap-on-raspbian


  1. アダルトサイトの閲覧やマルウェアの通信を阻害する目的でも用いられる場合がある
  2. 一般的には DNS フィルタリングと呼ばれている
  3. https://snapcraft.io/

自宅のネットワークに YAMAHA RTX830 / WLX212 を導入した

タイトルの通り、自宅のルータに YAMAHA RTX830 を、無線 LAN アクセスポイントに YAMAHA WLX212 を導入してみた。 今回は、それに至る背景や使ってみての所感を述べる。 なお、YAMAHA 製のネットワーク機器を扱うのは、これが初めての経験だった。

YAMAHA RTX830 の筐体デザインが気に入っている

もくじ

業務用のネットワーク機器について

一般的に、ネットワーク機器には大きく分けて個人向けと法人向け (業務用) の 2 通りの製品がある。 今回導入したのは、その中で業務用に該当する。

業務用のネットワーク機器には、一般的に次のようなメリットがある。

  • 安定性が高い
  • 機能とカスタマイズ性が高い

安定性が高いというのは、文字通り長期に渡って安定した稼働が期待できる。 例えば、不安定な動作にならず再起動なしに数年間でも稼働し続けるといったことが業務用のネットワーク機器には求められる。 また、ハードウェア的にもメーカーが技術仕様として MTBF (平均故障間隔) を数十年と規定している場合がある 1。 主観的には、機器に高い負荷がかかるような状況でも動作が不安定になりにくい傾向があるように思う。

機能とカスタマイズ性が高いというのは、分かりやすいところだと VPN が使えたり 2 回線や機器の冗長構成が組めたりする。 ただし、これはメリットでもある一方で扱うのに一定の知識が求められるデメリットにもなりうる。 というのも、一般的に業務用のネットワーク機器は Web UI (GUI) ですべての設定が完結しない場合が多い。 代わりに、すべての機能を扱うにはコマンドライン (CUI) で設定する必要がある。

また、業務用のネットワーク機器の明確なデメリットとして「新品の値段が高い」という点がある。 現在の相場で、新品の RTX830 は 5 万円弱、WLX212 も 2 万円強はする。 一方で、個人向けのブロードバンドルータでローエンドモデルなら 1 万円しない選択肢も存在する。 ただし、値段に関しては機器の信頼性やサポート期間、多岐にわたる機能の開発から QA やサポートまで考慮に含める必要がある。

導入に至った背景について

特に大したことはしていないものの、以前から自宅のネットワークには業務用の機器を導入していた。

まず、ルータについては、とある業務用のルータを 10 年以上に渡って使っていた。 ただし、メーカーによる製品のサポート自体は継続しているものの、新規の機能追加は停止している状態にあった。 なので、最近はリプレースの時期を伺っていた。

次に、無線 LAN のアクセスポイントについてはコロナ禍の時期 (2020 年) に業務用の製品にリプレースしていた。 というのも、自宅で仕事をしていると 1 日に 1 回程度は、不定期なタイミングで無線 LAN のアクセスポイントが不安定になることに気づいたため。 より具体的には、数分間に渡って著しいパケットロスや RTT の増大が見られた 3

個人向けのアクセスポイントは、想定している同時接続台数が少ない場合が多い。 ローエンドのモデルなどは特に、仕様上の同時接続数を 20 台程度に規定していたりする。 そういった事情もあって、自宅で無線 LAN につながっている機器の台数を考えると、性能が不足している可能性があったのでリプレースしていた。

そういった背景から、まずはルータのリプレースについて考えていた。 せっかくなら使ったことのないメーカーを、ということで国内の YAMAHA RTX シリーズと NEC UNIVERGE IX シリーズが候補に上がった。

YAMAHA RTX シリーズに関しては、概ね次のような長所が考えられた。 とはいえ、結局のところ最終的な選定理由としては YAMAHA のネットワーク機器になんとなく憧れがあった点が大きい。

  • ファームウェアの入手性に優れる
  • ドキュメントを含めてメーカーのサポートが手厚い
  • Web 上に利用事例が多く見つかる (ユーザ数が多い)

ドキュメントや Web 上の利用事例がたくさん見つかる点は、新しいコマンド体系を理解する上で大いに役立った。 2023 年の日経による調査では、企業ネットワークにおけるルータのシェアは YAMAHA が 1 位を獲得しているらしい。 特に、中小規模のネットワークでよく使われているようだ。

xtech.nikkei.com

ちなみに「ファームウェアの入手性に優れる」という点は、業務用のネットワーク機器を扱う際には注意すべきポイントになっている。 というのも、メーカーによっては新しいファームウェアを入手するのに販売代理店を経由したり、何らかの契約を必要とする場合があるため。

なお、NEC UNIVERGE IX シリーズであれば、次のような長所が考えられる。

  • サポート期間中の製品も含めて中古の機材が安価に入手できる
  • 機器をクラウドで管理できる NetMeister サービスが無料で利用できる
    • 初期のファームウェアが v10 系以降なら NetMeister 経由でファームウェアをアップデートできる
  • コマンド体系が Cisco に近いので使ったことがある人にとっては違和感が少ない

上記の、サポート期間中の製品が安く手に入るというのは明確な長所になるはず。 製品によっては、なんなら個人向けのブロードバンドルータよりも安上がりかもしれない。

利用までの下準備について

前述のとおり、業務用のネットワーク機器はコマンドラインの操作を必要とする場合が多い。 そのため、事前にコンソールポート経由で設定する方法は用意しておいた方が良い。

典型的には USB シリアル変換ケーブルと、コンソールケーブルを組み合わせて使う。 今だと間の D-SUB 9 ピンを不要にした USB - RJ45 のケーブルも一般的なようだ。

以下の製品であれば、現行の macOS ならドライバを別途導入することなく利用できるはず。

使用するコンソールケーブルのピンアサインは Cisco と互換性がある場合が多い。 前述の YAMAHA RTX シリーズと NEC UNIVERGE IX シリーズは、いずれも互換性がある。 なので、純正品を選ばなくても Cisco 互換のケーブルを利用できる。

なお、単にコマンドを入力するだけなら Web UI や telnet などからでもできる。 しかし、設定を間違えた場合などを考えると、あらかじめコンソールポートは使えるようにしておいた方が良い。

導入した製品について

今回は、せっかくならと無線 LAN アクセスポイントの WLX212 や L2 スイッチの SWX2110-5G も同時に購入した。 ネットワーク機器を YAMAHA で揃えると Web UI の「LANマップ機能」で一括管理できるらしい。

RTX830

RTX830 は小規模拠点向けのエッジルータのひとつ。 発売が 2017 年なので、そろそろ時期的に後継機が出そうな雰囲気ではある。

network.yamaha.com

我が家では、以下のような設定で使っている。 IPv4 over IPv6 サービスと PPPoE を 1 台で併用できるあたりが業務用ルータの良いところ。 なお、キャリア回線には NTT のフレッツひかりを利用している。

  • IPv6 は IPoE (光電話なし) で降ってくる RA をプロキシする
  • IPv4 はメインの経路として transix の DS-Lite (RFC6333) を利用する
    • メインの経路が使えない場合のバックアップ経路に ISP の PPPoE を利用する
  • リモートアクセス VPN として IKEv2 を利用する
    • IPsec 関連のトラフィックはポリシールーティングで全て PPPoE に向ける
    • VPN のエンドポイントになるグローバル IP アドレスはネットボランチDNSサービス 4 を使って FQDN で解決できるようにする

ちなみに Android 12 以降はリモートアクセス VPN のプロトコルに L2TP/IPsec が使えなくなったらしい。 そんな背景もあってリモートアクセス VPN は IKEv2 で統一することにした。

WLX212

WLX212 は 2020 年に発売された小規模拠点向けの無線 LAN アクセスポイント。 スペック上の同時接続数は 2.4GHz / 5GHz の各バンドあたり 50 台で、最大 100 台になっている。 対応している無線 LAN の規格は Wi-Fi 5 (IEEE 802.11ac) まで。

network.yamaha.com

なお、電源アダプタが同梱されていない点には注意が必要になる。 この製品は PoE (IEEE 802.3af) に対応しているため。 もし、PoE に対応したスイッチやインジェクタがあれば、電源アダプタがなくても動作する。

network.yamaha.com

WLX212 に関しても、コンソールポートからコマンドラインで設定できる。 とはいえ、機能的には無線 LAN アクセスポイントに過ぎないので Web UI からの設定でも十分に事足りる。 YAMAHA のルータと併用している場合には、ルータの Web UI からリバースプロキシの機能で管理画面にアクセスできる。 また、LANマップ機能を使うと接続している機器が一元的に確認できるので便利だった。

ちなみに、併売されている後継機種として WLX222 がある。 予算に余裕があれば、こちらを選ぶのも良さそう。 WLX212 と比べて、次のような点が優れている。

  • 2.5GbE に対応している
  • Wi-Fi 6 (IEEE 802.11ax) に対応している
  • バンドあたりの同時接続数が 70 台 (最大 140 台) に増えている

SWX2110-5G

タグ VLAN (IEEE 802.1Q) に対応していて 1 万円しなかったので、ついでに導入してみた L2 スイッチ。 ただし、PoE には対応していない。

network.yamaha.com

このスイッチは、YAMAHA 製のルータと組み合わせて利用するのが前提になっている感じ。 基本的に、ルータの Web UI 経由で設定する。 今のところタグ VLAN を使っていないので、本当にただのスイッチングハブになっている。 将来的にサブネットを増やすときは活躍してくれるかもしれない。

導入後の所感について

使ってみての所感としては、まず導入した機器の機能や性能に満足している。 そして、以下の点が収穫だったと感じる。

  • YAMAHA のコマンド体系に慣れることができた
  • 提供されている機能やサービスを使いながら把握できた

現状の構成における改善点は、ネットワークの管理がオンプレミスになっている点が挙げられる。 そうした意味では Yamaha Network Organizer (YNO) を使ったクラウドでの管理が、個人ユーザはより安価にできると嬉しい。 たとえば物理的に遠方にある実家のネットワークに導入するという観点では、機器をクラウドで管理したい気持ちがある 5。 この点は、現時点では NEC UNIVERGE IX を購入して無料の NetMeister 経由で使うのが良いのかなと思っている。 実家と異なるメーカーで IPsec VPN を張るのも楽しそうだ。


  1. YAMAHA のネットワーク機器に関しては MTBF を公表していないようだ
  2. 個人向けでもハイエンドな製品であれば一部のリモートアクセス VPN のプロトコルをサポートしている場合がある
  3. もちろん、その時間帯に電子レンジを使っていたとかそういう話でもなく
  4. YAMAHA の製品を使っていると無料で利用できる公式の DDNS サービス
  5. オンプレミスの管理は、設定を間違えてリモートから操作できなくなるのが怖い

データベーススペシャリスト試験に合格した

今回 2023 年秋期のデータベーススペシャリスト試験を受験して合格できた。 後から振り返ることができるように、受験に関する諸々について書き留めておく。

www.ipa.go.jp

受験のモチベーションとしては、データベースについて漠然とした知識の不足を感じていたため。 試験の勉強を通して、その不足を補いたい気持ちがあった。 また、情報処理安全確保支援士試験とネットワークスペシャリスト試験に合格していたことも影響している。 データベーススペシャリスト試験に合格すれば、午後が記述式の高度試験が全て揃う状況だった 1

もくじ

試験内容について

データベーススペシャリスト試験の内容は午前 I, II と午後 I, II の 4 つに分かれている。 すべての内容で基準点 (60 / 100 点) を満たせば合格になる。

今回の受験におけるスコアは次のとおり。 午後 I と II が、どちらもギリギリだったことが分かる。

データベーススペシャリスト試験のスコア

午前 I と II については四択の選択問題で、午後 I と II が記述問題になっている。 午前 I は他のスキルレベル 4 の試験と共通のため、午前 II 以降が試験で独自の問題になる。

午前 I については、別のスキルレベル 4 の試験で基準点以上を取っているか、応用情報技術者試験に合格している場合、2 年以内は申請すれば受験が免除される。 今回のケースでは、2023 年の春期にネットワークスペシャリスト試験に合格していたため、申請して受験が免除された。

勉強方法について

情報処理技術者試験において、午前の選択問題は一定の割合で過去に出題された問題がほとんどそのまま出題される。 そのため、ひたすら過去問を解くだけで対策できる。 今回に関しても、直近の数年分を順番に解いていった。 そして 80% 程度が安定して取れるようになった時点で午前の対策を完了とした。

午後問題も、基本的には過去問を解いて対策した。 なお、データベーススペシャリスト試験の午後問題には、午後 I と午後 II の両方で必ず出題されるジャンルがある。 それが「概念データモデルと関係スキーマ」という問題で、受験者からは「お絵かき問題」という通称で呼ばれることがある。 お絵かき問題と呼ばれる所以は、未完成の状態で与えられる概念データモデルの図に線を書き入れて完成させるため。

そして、過去問を解いていくと、どうやら自分が「お絵かき問題」を苦手としていることが分かってきた。 より具体的には、正解を導くことはできても時間がかかってしまう。 この点は、特に午後 I において問題となった。 なぜなら、午後 I はとにかく時間との戦いになるため。 午後 I は 90 分で大問 3 問から 2 問を選んで解くため、1 問あたり 45 分しか使えない。 分からない部分があっても、時間配分を決めてある程度は見切りをつけていかないと、最終的に全く時間が足りなくなる。

午後の採点において、空欄は確実にゼロ点になるとしても、何かそれっぽいことさえ書いてあれば多少の部分点は期待できる。 そのため、途中までの回答が完璧でも、時間を使い果たして残りの空欄が目立つような事態は避けなければいけない。 午後 II は、1 問あたりの文章量は増えるものの 120 分で大問 2 問から 1 問を選んで解くため、時間の制約は相対的に緩くなる。

直近数年の傾向において「お絵かき問題」は午後 I で 1 問、午後 2 で 1 問が出題されていた。 つまり、いずれにおいても解かずに済ませることは可能だった。 そういった背景から、本番において「お絵かき問題」は選ばないことをあらかじめ決めた。

この選択をするリスクは、スコアが出題内容に大きく依存する点にあった。 なぜなら、お絵かき問題以外には、それほど明確な出題傾向が見られないため。 とはいえ「お絵かき問題」の回答スピードを短期間で上げることは難しいと判断してリスクは受容することにした。 私見ながら、データベーススペシャリスト試験は、他の試験よりも事前に必要とされる知識の量は相対的に少なく感じる。 その代わりに、長文から素早く正解を読み取る力が試される。

なお、受験した 2023 年の秋期試験においては、午後 I でお絵かき問題が問 1 と問 2 の 2 問で出題された。 つまり、そもそもの前提が崩れて午後 I ではお絵かき問題を最低 1 問は選ぶ必要に迫られた。 なお、問 3 に関しては比較的得意とする SQL によるデータ分析を扱う出題だったため、なんとか命拾いした感じだった。

その他の勉強としては、自分の中で理解が足りていない部分などを把握するために以下の書籍を購入して読んだ。 ただ、受験を済ませた上での感想としては、あまり内容が十分とは言えないように思う。 この点は、受験対策という観点と、リレーショナルデータベースを扱う上での知識という観点の、どちらにも当てはまる。

所感について

データベーススペシャリスト試験の出題傾向において、直近ではデータ基盤や SQL によるデータ分析を問う出題が増えている。 この点は、以前よりもアナリスト系の職種が取得を目指しやすくなったかもしれない。 特に、日頃から SQL を業務で使っている場合には、SQL に関する問いはボーナス問題になる 2

なお、自分自身に関して言えば「データベーススペシャリスト」という名称には実力が全く追いついていないように感じている。 この点は、試験とは別枠で今後も勉強していきたい。


  1. エンベデッドスペシャリスト試験は 2023 年の秋期から午後 II が論述式に変更された
  2. ウィンドウ関数や JOIN の種類を問う問題が頻出する

Polars と PySpark / スケールアップとスケールアウト

これは Polars Advent Calendar 2023 の 4 日目に対応したエントリです。

qiita.com


Polars と PySpark は操作方法が似ていると言われることがある。 そこで、今回はいくつかの基本的な操作について、実際に両者でコードを比較してみた。 また、それぞれの思想的な違いなどについても私見を述べる。

最初に書いたとおり、これは Polars のアドベントカレンダーのために書かれたエントリになる。 そのため、まずは簡単に PySpark 自体の説明をしておきたい。

PySpark というのは、Apache Spark という分散処理フレームワークを Python から操作するためのインターフェイスになる。 Apache Spark 自体は Scala で書かれているため、py4j というライブラリを使って Python バインディングを提供している。

Polars と比較対象になることからも分かるように、PySpark にもデータフレームの API が存在している。 ただし、データフレームは最初からあったわけではなく、バージョン 1.3 から追加された。 Apache Spark は RDD (Resilient Distributed Dataset; 耐障害性分散データセット) と呼ばれるデータ構造を動作の基本的な単位としている。 そのため、RDD という低レベル API に対する高レベル API としてデータフレームも作られている。

なお、Apache Spark は、あくまで複数台の計算機で構成されたクラスタ (以下、計算機クラスタ) の上で分散処理をするための仕組みに過ぎない。 そのため、計算機クラスタを用意して、それらのリソースを管理する部分は別でやる必要がある。 計算機クラスタのリソースを管理する部分は、次のような選択肢がある 1

  • Standalone Server 2
  • Hadoop YARN
  • Kubernetes

なお、上記の仕組みを使わずに、シングルノード (ローカルモード) で利用することもできる。 そのため、今回のエントリもローカルモードを使って検証していく。 ただし、シングルノードでの利用は、テストを目的とする以外にはほとんどメリットがない。 また、ローカルモードで動作したコードが分散処理させたときに動かないパターンもあるので注意が必要になる 3

もくじ

思想の違いについて

Polars と PySpark の思想の違いは、タイトルにもあるとおりスケールアップとスケールアウトのアプローチで説明できる。

PySpark は、大きなデータであっても複数の計算機が分担しながら処理をする。 それぞれの計算機が担当するのは元のデータの一部分なので、個々の計算機が持つ CPU コアやメモリの上限に影響を受けにくい (スケールアウト)。 一方で、事前に計算機クラスタを用意する必要があったり、分散処理のオーバーヘッド 4 が存在するといったデメリットもある。

Polars は、それ単体では基本的にシングルノードでしか処理ができない。 そのため、大きなデータを扱うときは、相応に CPU コアやメモリをたくさん積んだ計算機を使うことになる (スケールアップ)。 事前の準備が簡単で、処理のオーバーヘッドも少ない一方、処理できるデータの規模については劣る 5

とはいえ、本当に Apache Spark のような分散処理をしなければ対応できないデータが世の中にどれだけあるのか、という話もある。 Polars や DuckDB といった、シングルノードでの分析におけるスケーラビリティを改善するソフトウェアが台頭しつつあるのは、その流れを反映してのことだろう。

そして、Polars と PySpark は「どちらを使うか」という二者択一とは限らない。 たとえば PySpark で記述した分散処理において、個々の計算機で実行する処理の中身が Polars になっている、というパターンもありうる。 特に UDF (User Defined Function) を書くような場面では、分散処理していても個々の計算機の CPU 資源を有効に使えていない、というケースは十分に考えられる。

下準備

前置きが長くなってしまったけど、ここからは実際に Polars と PySpark を使っていく。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-89-generic x86_64
$ python -V
Python 3.10.12
$ pip list | egrep "(polars|pyspark|pandas|scikit-learn)"
pandas          2.1.3
polars          0.19.18
pyspark         3.5.0
scikit-learn    1.3.2

まずは必要なパッケージをインストールする。 前述したとおり Apache Spark は Scala で書かれているため、PySpark の動作には Java のランタイムが必要になる。

$ sudo apt-get install python3-venv openjdk-8-jdk

Python の仮想環境を用意してライブラリをインストールする。

$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install "polars[pyarrow]" pyspark pandas scikit-learn

Python のインタプリタを起動する。

(venv) $ python

あらかじめ scikit-learn を使って OpenML から適当なデータセットをダウンロードしておく。 ここでは Diamonds データセットにした。 この段階では Pandas のデータフレームとして読み込んでいる。

>>> from sklearn.datasets import fetch_openml
>>> df_pandas, _ = fetch_openml(
...     "diamonds",
...     version=1,
...     as_frame=True,
...     return_X_y=True,
...     parser="pandas"
... )
>>> df_pandas.head()
   carat      cut color clarity  depth  table     x     y     z
0   0.23    Ideal     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  Premium     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23     Good     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  Premium     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31     Good     J     SI2   63.3   58.0  4.34  4.35  2.75

そして、Pandas のデータフレームを、それぞれのフレームワークのデータフレームに変換しておこう 6

Polars

Polars では polars.from_pandas() という関数を使うことで、Pandas のデータフレームから Polars のデータフレームに変換できる。

>>> import polars as pl
>>> df_polars = pl.from_pandas(df_pandas)
>>> df_polars
shape: (53940, 9)
┌───────┬───────────┬───────┬─────────┬───┬───────┬──────┬──────┬──────┐
│ carat ┆ cut       ┆ color ┆ clarity ┆ … ┆ table ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---       ┆ ---   ┆ ---     ┆   ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ cat       ┆ cat   ┆ cat     ┆   ┆ f64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═══════════╪═══════╪═════════╪═══╪═══════╪══════╪══════╪══════╡
│ 0.23  ┆ Ideal     ┆ E     ┆ SI2     ┆ … ┆ 55.03.953.982.43 │
│ 0.21  ┆ Premium   ┆ E     ┆ SI1     ┆ … ┆ 61.03.893.842.31 │
│ 0.23  ┆ Good      ┆ E     ┆ VS1     ┆ … ┆ 65.04.054.072.31 │
│ 0.29  ┆ Premium   ┆ I     ┆ VS2     ┆ … ┆ 58.04.24.232.63 │
│ …     ┆ …         ┆ …     ┆ …       ┆ … ┆ …     ┆ …    ┆ …    ┆ …    │
│ 0.72  ┆ Good      ┆ D     ┆ SI1     ┆ … ┆ 55.05.695.753.61 │
│ 0.7   ┆ Very Good ┆ D     ┆ SI1     ┆ … ┆ 60.05.665.683.56 │
│ 0.86  ┆ Premium   ┆ H     ┆ SI2     ┆ … ┆ 58.06.156.123.74 │
│ 0.75  ┆ Ideal     ┆ D     ┆ SI2     ┆ … ┆ 55.05.835.873.64 │
└───────┴───────────┴───────┴─────────┴───┴───────┴──────┴──────┴──────┘

PySpark

次に PySpark の場合、まずは SparkSession のインスタンスを作成する必要がある。

>>> from pyspark.sql import SparkSession
>>> spark = SparkSession.builder.getOrCreate()

作成した SparkSession のインスタンスで sparkContext というプロパティを参照しておこう。 すると、master=local[*] という値が確認できる。 これは、前述したシングルノードのローカルモードで動作していることを示している。

>>> spark.sparkContext
<SparkContext master=local[*] appName=pyspark-shell>

PySpark では SparkSession#createDataFrame() というメソッドを使うと Pandas のデータフレームを PySpark のデータフレームに変換できる。

>>> df_spark = spark.createDataFrame(df_pandas)

ただし、得られたデータフレームの変数を参照しても、中身が表示されない。 これは PySpark が遅延評価を操作の基本としている点が関係している。 まだ、この段階では「Pandas のデータフレームを PySpark のデータフレームに変換する」という処理を、これからすることしか決まっていない。 つまり、データフレームの内容は、実際に評価を実行しない限りは得られない 7

>>> df_spark
DataFrame[carat: double, cut: string, color: string, clarity: string, depth: double, table: double, x: double, y: double, z: double]

試しに head() メソッドを実行してみよう。 すると、評価が実行されて Row クラスのインスタンスが入ったリストとして結果が得られる。

>>> df_spark.head(n=5)
[Row(carat=0.23, cut='Ideal', color='E', clarity='SI2', depth=61.5, table=55.0, x=3.95, y=3.98, z=2.43), Row(carat=0.21, cut='Premium', color='E', clarity='SI1', depth=59.8, table=61.0, x=3.89, y=3.84, z=2.31), Row(carat=0.23, cut='Good', color='E', clarity='VS1', depth=56.9, table=65.0, x=4.05, y=4.07, z=2.31), Row(carat=0.29, cut='Premium', color='I', clarity='VS2', depth=62.4, table=58.0, x=4.2, y=4.23, z=2.63), Row(carat=0.31, cut='Good', color='J', clarity='SI2', depth=63.3, table=58.0, x=4.34, y=4.35, z=2.75)]

上記は見にくいので、結果を行単位で処理したいときはまだしも、中身を軽く見たいだけなら普段は show() メソッドを使った方が良いだろう。

>>> df_spark.show(n=5)
+-----+-------+-----+-------+-----+-----+----+----+----+
|carat|    cut|color|clarity|depth|table|   x|   y|   z|
+-----+-------+-----+-------+-----+-----+----+----+----+
| 0.23|  Ideal|    E|    SI2| 61.5| 55.0|3.95|3.98|2.43|
| 0.21|Premium|    E|    SI1| 59.8| 61.0|3.89|3.84|2.31|
| 0.23|   Good|    E|    VS1| 56.9| 65.0|4.05|4.07|2.31|
| 0.29|Premium|    I|    VS2| 62.4| 58.0| 4.2|4.23|2.63|
| 0.31|   Good|    J|    SI2| 63.3| 58.0|4.34|4.35|2.75|
+-----+-------+-----+-------+-----+-----+----+----+----+
only showing top 5 rows

これで下準備が整った。

特定のカラムを取り出す

まずは特定のカラムを取り出してみよう。

Polars

Polars であれば特定のカラムを取り出すのに select() メソッドを使う。

>>> df_polars.select(["x", "y", "z"])
shape: (53_940, 3)
┌──────┬──────┬──────┐
│ x    ┆ y    ┆ z    │
│ ---  ┆ ---  ┆ ---  │
│ f64  ┆ f64  ┆ f64  │
╞══════╪══════╪══════╡
│ 3.953.982.43 │
│ 3.893.842.31 │
│ 4.054.072.31 │
│ 4.24.232.63 │
│ …    ┆ …    ┆ …    │
│ 5.695.753.61 │
│ 5.665.683.56 │
│ 6.156.123.74 │
│ 5.835.873.64 │
└──────┴──────┴──────┘

上記は単純にカラム名を文字列で指定しているけど、代わりに Expression を使うことも考えられる。

>>> df_polars.select([pl.col("x"), pl.col("y"), pl.col("z")]).head(n=5)
shape: (5, 3)
┌──────┬──────┬──────┐
│ x    ┆ y    ┆ z    │
│ ---  ┆ ---  ┆ ---  │
│ f64  ┆ f64  ┆ f64  │
╞══════╪══════╪══════╡
│ 3.953.982.43 │
│ 3.893.842.31 │
│ 4.054.072.31 │
│ 4.24.232.63 │
│ 4.344.352.75 │
└──────┴──────┴──────┘

PySpark

PySpark の場合も select() メソッドを使うのは変わらない。 ただし、前述したとおり遅延評価が原則なのでメソッドの返り値からそのまま結果を確認することはできない。

>>> df_spark.select(["x", "y", "z"])
DataFrame[x: double, y: double, z: double]

繰り返しになるけど、処理した結果を得るためには評価しなければいけない。

>>> df_spark.select(["x", "y", "z"]).show(n=5)
+----+----+----+
|   x|   y|   z|
+----+----+----+
|3.95|3.98|2.43|
|3.89|3.84|2.31|
|4.05|4.07|2.31|
| 4.2|4.23|2.63|
|4.34|4.35|2.75|
+----+----+----+
only showing top 5 rows

また、PySpark に関しても、文字列でカラムを指定する代わりに Polars の Expression に相当する指定方法がある。

>>> from pyspark.sql import functions as F
>>> df_spark.select([F.col("x"), F.col("y"), F.col("z")]).show(n=5)
+----+----+----+
|   x|   y|   z|
+----+----+----+
|3.95|3.98|2.43|
|3.89|3.84|2.31|
|4.05|4.07|2.31|
| 4.2|4.23|2.63|
|4.34|4.35|2.75|
+----+----+----+
only showing top 5 rows

特定のカラムを追加する

続いてはデータフレームにカラムを追加するパターンを試してみよう。

Polars

Polars であれば with_columns() メソッドを使う。 ここでは xy カラムの内容を足して x_plus_y というカラムを追加した。

>>> df_polars.with_columns((pl.col("x") + pl.col("y")).alias("x_plus_y")).head(n=5)
shape: (5, 10)
┌───────┬─────────┬───────┬─────────┬───┬──────┬──────┬──────┬──────────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ … ┆ x    ┆ y    ┆ z    ┆ x_plus_y │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆   ┆ ---  ┆ ---  ┆ ---  ┆ ---      │
│ f64   ┆ cat     ┆ cat   ┆ cat     ┆   ┆ f64  ┆ f64  ┆ f64  ┆ f64      │
╞═══════╪═════════╪═══════╪═════════╪═══╪══════╪══════╪══════╪══════════╡
│ 0.23  ┆ Ideal   ┆ E     ┆ SI2     ┆ … ┆ 3.953.982.437.93     │
│ 0.21  ┆ Premium ┆ E     ┆ SI1     ┆ … ┆ 3.893.842.317.73     │
│ 0.23  ┆ Good    ┆ E     ┆ VS1     ┆ … ┆ 4.054.072.318.12     │
│ 0.29  ┆ Premium ┆ I     ┆ VS2     ┆ … ┆ 4.24.232.638.43     │
│ 0.31  ┆ Good    ┆ J     ┆ SI2     ┆ … ┆ 4.344.352.758.69     │
└───────┴─────────┴───────┴─────────┴───┴──────┴──────┴──────┴──────────┘

PySpark

PySpark は withColumns() というメソッドを使う。 見て分かるとおり PySpark は命名規則にキャメルケースを利用している。 Python は PEP8 に代表されるスネークケースを利用したコーディング規約を採用する場合が多いため違和感を覚えるかもしれない。

>>> df_spark.withColumns({"x_plus_y": F.col("x") + F.col("y")}).show(n=5)
+-----+-------+-----+-------+-----+-----+----+----+----+-----------------+
|carat|    cut|color|clarity|depth|table|   x|   y|   z|         x_plus_y|
+-----+-------+-----+-------+-----+-----+----+----+----+-----------------+
| 0.23|  Ideal|    E|    SI2| 61.5| 55.0|3.95|3.98|2.43|             7.93|
| 0.21|Premium|    E|    SI1| 59.8| 61.0|3.89|3.84|2.31|             7.73|
| 0.23|   Good|    E|    VS1| 56.9| 65.0|4.05|4.07|2.31|8.120000000000001|
| 0.29|Premium|    I|    VS2| 62.4| 58.0| 4.2|4.23|2.63|             8.43|
| 0.31|   Good|    J|    SI2| 63.3| 58.0|4.34|4.35|2.75|             8.69|
+-----+-------+-----+-------+-----+-----+----+----+----+-----------------+
only showing top 5 rows

条件で行を絞り込む

次に特定の条件で行を絞り込んでみよう。

Polars

Polars の場合は filter() メソッドで実現できる。 以下では、試しにカラット数が 3 以上の行を取り出している。

>>> df_polars.filter(pl.col("carat") > 3).head(n=5)
shape: (5, 9)
┌───────┬─────────┬───────┬─────────┬───┬───────┬──────┬──────┬──────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ … ┆ table ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆   ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ cat     ┆ cat   ┆ cat     ┆   ┆ f64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═════════╪═══════╪═════════╪═══╪═══════╪══════╪══════╪══════╡
│ 3.01  ┆ Premium ┆ I     ┆ I1      ┆ … ┆ 58.09.18.975.67 │
│ 3.11  ┆ Fair    ┆ J     ┆ I1      ┆ … ┆ 57.09.159.025.98 │
│ 3.01  ┆ Premium ┆ F     ┆ I1      ┆ … ┆ 56.09.249.135.73 │
│ 3.05  ┆ Premium ┆ E     ┆ I1      ┆ … ┆ 58.09.269.255.66 │
│ 3.02  ┆ Fair    ┆ I     ┆ I1      ┆ … ┆ 56.09.119.025.91 │
└───────┴─────────┴───────┴─────────┴───┴───────┴──────┴──────┴──────┘

PySpark

PySpark も filter() メソッドが使える。 同じ条件で絞り込んだ場合、記述方法もほとんど変わらない。

>>> df_spark.filter(F.col("carat") > 3).show(n=5)
+-----+-------+-----+-------+-----+-----+----+----+----+
|carat|    cut|color|clarity|depth|table|   x|   y|   z|
+-----+-------+-----+-------+-----+-----+----+----+----+
| 3.01|Premium|    I|     I1| 62.7| 58.0| 9.1|8.97|5.67|
| 3.11|   Fair|    J|     I1| 65.9| 57.0|9.15|9.02|5.98|
| 3.01|Premium|    F|     I1| 62.2| 56.0|9.24|9.13|5.73|
| 3.05|Premium|    E|     I1| 60.9| 58.0|9.26|9.25|5.66|
| 3.02|   Fair|    I|     I1| 65.2| 56.0|9.11|9.02|5.91|
+-----+-------+-----+-------+-----+-----+----+----+----+
only showing top 5 rows

特定の値で集約する

次に特定の値で集約して、それぞれのグループについて要約統計量などを求めてみよう。

Polars

Polars では、group_by() メソッドで集約してから、計算する内容を agg() メソッドなどで指定する。 ここでは試しに color カラムで集約して carat カラムの平均を、それぞれのグループで計算してみよう。

>>> df_polars.group_by("color").agg(pl.mean("carat").alias("color_mean_carat"))
shape: (7, 2)
┌───────┬──────────────────┐
│ color ┆ color_mean_carat │
│ ---   ┆ ---              │
│ cat   ┆ f64              │
╞═══════╪══════════════════╡
│ J     ┆ 1.162137         │
│ G     ┆ 0.77119          │
│ I     ┆ 1.026927         │
│ D     ┆ 0.657795         │
│ F     ┆ 0.736538         │
│ E     ┆ 0.657867         │
│ H     ┆ 0.911799         │
└───────┴──────────────────┘

PySpark

PySpark であっても、やり方はほとんど変わらない。 ただし、メソッド名はキャメルケースなので group_by() ではなく groupBy() になる。

>>> df_spark.groupBy("color").agg(F.mean("carat").alias("color_mean_carat")).show()
+-----+------------------+
|color|  color_mean_carat|
+-----+------------------+
|    F|0.7365384615384617|
|    E|0.6578666938858808|
|    D| 0.657794833948337|
|    J|1.1621367521367514|
|    G|0.7711902231668448|
|    I|1.0269273330874222|
|    H| 0.911799132947978|
+-----+------------------+

まとめ

さて、ここまで Polars と PySpark の基本的な操作方法を簡単に比べてきた。 両者を見比べて、どのように感じられただろうか。 たしかに、操作方法として似通っている部分はあるようだ。

一方で、PySpark については遅延評価が原則となる点が使い勝手として大きく異なっている。 個人的な感想を述べると、遅延評価しか使えないのは結構めんどくさい。 インタラクティブに操作しているときなどは特に、すぐに結果を見せてほしくなる。

これまで、評価のタイミングは決め打ちになっているフレームワークが多かった。 たとえば Pandas は即時評価しか使えず、PySpark は遅延評価しか使えない。 遅延評価は最適化を効かせやすいというメリットがある一方で、インタラクティブな操作にはあまり向いていないように感じる 8。 そうした意味で、即時評価と遅延評価を使い分けることができる Polars は、使いやすさとパフォーマンスのバランスを上手くとった API になっているのではないだろうか。

とはいえ、もしもシングルノードで捌ききれないようなデータに直面したときは、PySpark (Apache Spark) の存在を思い出してもらいたい。 あるいは、選択肢のひとつになるかもしれないので。


  1. 以前は Apache Mesos もサポートされていたがバージョン 3.2 で非推奨になった
  2. Apache Spark が組み込みで提供している計算機クラスタを管理する仕組み
  3. せめてシングルノードでも内部で擬似的に分散処理をするような構成にするのが望ましい (Hadoop YARN Pseudo Distributed Mode など)
  4. PySpark の場合は Scala と Python の間で SerDe のオーバーヘッドもある
  5. Apache Spark であればテラバイトのデータを処理するのも割りと普通という印象がある
  6. CSV ファイルを読み込んでも良かったけど試しやすさを優先した
  7. 遅延評価を積み重ねる処理を Transformation、評価を実行する処理を Action という
  8. Vaex のように遅延評価を原則としていてもインタラクティブな操作で使い勝手を落としにくいように工夫した実装も存在する

Python: scikit-learn の set_output API で pandas との食べ合わせが改善された

これまで scikit-learn が提供する TransformerMixin の実装 1 は、出力に NumPy 配列を仮定していた。 そのため、pandas の DataFrame を入力しても出力は NumPy 配列になってしまい、使い勝手が良くないという問題があった。 この問題は、特に PipelineColumnTransformer を使って処理を組むときに顕在化しやすい。

しかし、scikit-learn v1.2 で set_output API が追加されたことで、この状況に改善が見られた。 そこで、今回は set_output API の使い方について書いてみる。

使った環境は次のとおり。

$ sw_vers    
ProductName:        macOS
ProductVersion:     13.6.2
BuildVersion:       22G320
$ python -V
Python 3.10.13
$ pip list | egrep "(pandas|scikit-learn)"
pandas          2.1.3
scikit-learn    1.3.2

もくじ

下準備

まずは必要なパッケージをインストールしておく。

$ pip install scikit-learn pandas

そして Python のインタプリタを起動しておこう。

$ python

scikit-learn と pandas の食べ合わせの悪さについて

初めに、前述した scikit-learn と pandas の食べ合わせの悪さについて確認しておく。

最初にサンプルとして Iris データセットを pandas の DataFrame の形式で読み込む。

>>> from sklearn.datasets import load_iris
>>> X, y = load_iris(as_frame=True, return_X_y=True)
>>> type(X)
<class 'pandas.core.frame.DataFrame'>
>>> X.head()
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0                5.1               3.5                1.4               0.2
1                4.9               3.0                1.4               0.2
2                4.7               3.2                1.3               0.2
3                4.6               3.1                1.5               0.2
4                5.0               3.6                1.4               0.2
>>> type(y)
<class 'pandas.core.series.Series'>
>>> y.head()
0    0
1    0
2    0
3    0
4    0
Name: target, dtype: int64

そして scikit-learn の TransformerMixin を実装したサンプルとして StandardScaler のインスタンスを用意する。

>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()

先ほど読み込んだ Iris データセットの DataFrame を学習および変換してみよう。

>>> X_scaled = scaler.fit_transform(X)

すると、返り値は NumPy 配列になってしまう。

>>> type(X_scaled)
<class 'numpy.ndarray'>
>>> X_scaled[:5]
array([[-0.90068117,  1.01900435, -1.34022653, -1.3154443 ],
       [-1.14301691, -0.13197948, -1.34022653, -1.3154443 ],
       [-1.38535265,  0.32841405, -1.39706395, -1.3154443 ],
       [-1.50652052,  0.09821729, -1.2833891 , -1.3154443 ],
       [-1.02184904,  1.24920112, -1.34022653, -1.3154443 ]])

pandas の DataFrame を入れたのに返ってくるのが NumPy 配列だと、特にパイプライン的に処理を繰り返すような場面で使い勝手が良くない。

TransformerMixin#set_output(transform="pandas") を指定する

では、次に今回の主題となる set_output API を使ってみよう。

やることは単純で、先ほど用意した StandardScaler インスタンスに対して set_output() メソッドを呼ぶ。 このとき、引数として transform="pandas" を指定するのがポイントになる。

>>> scaler.set_output(transform="pandas")
StandardScaler()

この状態で、もう一度 DataFrame を入力として学習と変換をしてみよう。

>>> X_scaled = scaler.fit_transform(X)

すると、今度は返ってくる値の型が pandas の DataFrame になっている。

>>> type(X_scaled)
<class 'pandas.core.frame.DataFrame'>
>>> X_scaled.head()
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0          -0.900681          1.019004          -1.340227         -1.315444
1          -1.143017         -0.131979          -1.340227         -1.315444
2          -1.385353          0.328414          -1.397064         -1.315444
3          -1.506521          0.098217          -1.283389         -1.315444
4          -1.021849          1.249201          -1.340227         -1.315444

このように TransformerMixin のサブクラスは、set_output API を使うことで pandas の DataFrame を返すことができるようになった。

ちなみに学習済みのインスタンスについて、途中から返す値の型を変えることもできる。 試しに set_output() メソッドで transform="default" を指定して元に戻してみよう。

>>> scaler.set_output(transform="default")
StandardScaler()

そして既存のデータを学習せずに変換だけしてみる。 すると、今度は NumPy 配列が返ってきた。

>>> X_scaled = scaler.transform(X)
>>> type(X_scaled)
<class 'numpy.ndarray'>
>>> X_scaled[:5]
array([[-0.90068117,  1.01900435, -1.34022653, -1.3154443 ],
       [-1.14301691, -0.13197948, -1.34022653, -1.3154443 ],
       [-1.38535265,  0.32841405, -1.39706395, -1.3154443 ],
       [-1.50652052,  0.09821729, -1.2833891 , -1.3154443 ],
       [-1.02184904,  1.24920112, -1.34022653, -1.3154443 ]])

scikit-learn の Pipeline と組み合わせて使う

また、set_output API は Pipeline についても対応している。 試しに PCAStandardScaler を直列に実行する Pipeline を用意して試してみよう。

先ほどはインスタンスの生成と set_output() メソッドの呼び出しを行で分けていた。 今度はメソッドチェーンで一気に設定してみよう。

>>> from sklearn.decomposition import PCA
>>> from sklearn.pipeline import Pipeline
>>> steps = [
...     ("pca", PCA()),
...     ("normalize", StandardScaler()),
... ]
>>> pipeline = Pipeline(steps=steps).set_output(transform="pandas")

作成した Pipeline でデータの学習と変換をしてみよう。

>>> X_transformed = pipeline.fit_transform(X)
>>> type(X_transformed)
<class 'pandas.core.frame.DataFrame'>
>>> X_transformed.head()
       pca0      pca1      pca2      pca3
0 -1.309711  0.650541 -0.100152 -0.014703
1 -1.324357 -0.360512 -0.755094 -0.643570
2 -1.409674 -0.295230  0.064222 -0.129774
3 -1.339582 -0.648304  0.113227  0.491164
4 -1.331469  0.665527  0.323182  0.398117

ちゃんと pandas の DataFrame で返ってきていることが確認できる。

最終段を BaseEstimator のサブクラスにした上で予測まで扱えることも確認しておこう。 先ほどのパイプラインの最終段に LogisticRegression を挿入する。

>>> from sklearn.linear_model import LogisticRegression
>>> steps = [
...     ("pca", PCA()),
...     ("normalize", StandardScaler()),
...     ("lr", LogisticRegression()),
... ]
>>> pipeline = Pipeline(steps=steps).set_output(transform="pandas")

データを train_test_split() 関数で分割する。

>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42)

学習データを Pipeline に学習させる。

>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('pca', PCA()), ('normalize', StandardScaler()),
                ('lr', LogisticRegression())])

テストデータを Pipeline に推論させる。

>>> y_pred = pipeline.predict(X_test)

推論した内容を Accuracy で評価する。

>>> from sklearn.metrics import accuracy_score
>>> accuracy_score(y_test, y_pred)
0.8947368421052632

ひとまず予測の精度については横に置いておくとして、ちゃんと機能していそうなことが確認できた。

scikit-learn の ColumnTransformer と組み合わせて使う

さらに実用上は、特定のカラムに絞って処理をする ColumnTransformer と組み合わせて使うことになるはず。 この点も確認しておこう。

カテゴリ変数の処理をしたいので Diamonds データセットを読み込んでおく。

>>> from sklearn.datasets import fetch_openml
>>> X, y = fetch_openml(
...     "diamonds",
...     version=1,
...     as_frame=True,
...     return_X_y=True,
...     parser="pandas"
... )
>>> X.head()
   carat      cut color clarity  depth  table     x     y     z
0   0.23    Ideal     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  Premium     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23     Good     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  Premium     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31     Good     J     SI2   63.3   58.0  4.34  4.35  2.75
>>> y.head()
0    326
1    326
2    327
3    334
4    335
Name: price, dtype: int64

ちなみに、常に TransformerMixin を実装したクラスで pandas の DataFrame を返してほしいときは、次のようにしてグローバルに設定できる。

>>> from sklearn import set_config
>>> set_config(transform_output="pandas")

カテゴリ変数には OneHotEncoder を、連続変数には StandardScaler をかける ColumnTransformer を次のように用意する。

>>> from sklearn.preprocessing import OneHotEncoder
>>> from sklearn.preprocessing import StandardScaler
>>> ct_settings = [
...     ("cat_onehot", OneHotEncoder(sparse_output=False), ["cut", "color", "clarity"]),
...     ("num_normalize", StandardScaler(), ["carat", "depth", "table", "x", "y", "z"]),
... ]
>>> ct = ColumnTransformer(ct_settings)

試しにデータを学習および変換してみると、次のように結果が pandas の DataFrame で返ってくる。

>>> ct.fit_transform(X).head()
   cat_onehot__cut_Fair  cat_onehot__cut_Good  ...  num_normalize__y  num_normalize__z
0                   0.0                   0.0  ...         -1.536196         -1.571129
1                   0.0                   0.0  ...         -1.658774         -1.741175
2                   0.0                   1.0  ...         -1.457395         -1.741175
3                   0.0                   0.0  ...         -1.317305         -1.287720
4                   0.0                   1.0  ...         -1.212238         -1.117674

[5 rows x 26 columns]

上記の ColumnTransformer のインスタンスを、さらに Pipeline に組み込んでみよう。 最終段には RandomForestRegressor を配置する。

>>> from sklearn.ensemble import RandomForestRegressor
>>> steps = [
...     ("preprocessing", ct),
...     ("rf", RandomForestRegressor(n_jobs=-1)),
... ]
>>> pipeline = Pipeline(steps)

データを Random 5-Fold で RMSE について交差検証してみよう。

>>> from sklearn.model_selection import cross_validate
>>> from sklearn.model_selection import KFold
>>> folds = KFold(n_splits=5, shuffle=True, random_state=42)
>>> cv_result = cross_validate(pipeline, X, y, cv=folds, scoring="neg_root_mean_squared_error")

すると、次のようにテストデータのスコアが得られた。

>>> cv_result["test_score"]
array([-552.59282602, -536.20769256, -582.69130436, -559.43303878,
       -533.75354186])

上記についても性能の高低は別として、エンドツーエンドで評価まで動作することが確認できた。

まとめ

今回は scikit-learn v1.2 で追加された set_output API を試してみた。 TransformerMixin を実装したクラスが pandas の DataFrame を返せるようになったことで両者の食べ合わせが以前よりも良くなった。

参考

scikit-learn.org


  1. 具体例として OneHotEncoderStandardScaler などが挙げられる