洋食の日記

「だ・である」調ではなく「です・ます」調で書きはじめれば良かったなと後悔してる人のブログです

Rumale::SVMにLocally Linear SVMによる分類器を追加した

はじめに

Locally Linear Support Vector Machine(LL-SVM)は、多様体学習の考え方を利用して、線形SVM非線形分類器を実現する手法である。Rumale::SVMは、Ruby機械学習ライブラリであるRumaleと同様のインターフェースで、LIBLINEARやLIBSVMで実装されているSVMアルゴリズムによる分類器や回帰を利用できるものである。LIBLINEARやLIBSVMにアップデートがないと、Rumale::SVMもアップデートする機会がないのだが、SVM全般のgemと拡大解釈して、LL-SVMを実装してみることにした。

Ladicky, L., and Torr, P H.S., "Locally Linear Support Vector Machines," Proc. ICML'11, pp. 985--992, 2011.

インストールと使い方

LL-SVMの実行には、Numo::Linalg(もしくはNumo::TinyLinalg)とlbfgsbを必要とする。Rumale::SVMに含まれるその他のアルゴリズムは必要としないので、runtime-dependencyに含めていない。これらを一緒にインストールする。

gem install rumale-svm numo-tiny_linalg lbfgsb

例として、LIBSVM Dataにある手書き文字画像のデータセットPendigitsの分類を行う。

wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits
wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits.t

分類性能の評価のためrumale-evaluation_measureをインストールする。

gem install rumale-evaluation_measure

Pendigitsのデータを分類して、その分類の正確度を出力するスクリプトは以下の様になる。

require 'lbfgsb'
require 'numo/tiny_linalg'
Numo::Linalg = Numo::TinyLinalg unless defined?(Numo::Linalg)

require 'rumale/dataset'
require 'rumale/evaluation_measure'
require 'rumale/svm'

# Pendigitsデータを読み込む.
x_train, y_train = Rumale::Dataset.load_libsvm_file('pendigits')
x_test, y_test = Rumale::Dataset.load_libsvm_file('pendigits.t')

# LL-SVMによる分類器を生成する.
# パラメータである代表点数は128, 近傍数は8とした.
classifier = Rumale::SVM::LocallyLinearSVC.new(n_anchors: 128, n_neighbors: 8, random_seed: 42)

# 学習データで学習する.
classifier.fit(x_train, y_train)

# テストデータのラベルを推定する.
y_pred = classifier.predict(x_test)

# 推定結果の正確度を出力する.
acc = Rumale::EvaluationMeasure::Accuracy.new
puts format('Accuracy: %.1f%%', (100.0 * acc.score(y_test, y_pred)))

これを実行すると正確度は95.7%となった。

$ ruby example.rb
Accuracy: 95.7%

比較のために線形SVMやRBFカーネルによるカーネルSVMを試してみる。分類器の生成の箇所を以下で置き換えればよい。

# 線形SVMによる分類器を生成する.
classifier = Rumale::SVM::LinearSVC.new(random_seed: 42)
# カーネルSVMによる分類機を生成する.
# classifier = Rumale::SVM::SVC.new(kernel: 'rbf', gamma: 1e-3, random_seed: 42)

結果として、線形SVMの正確度は89.7%、カーネルSVMの正確度は95.4%となった。

$ ruby example.rb
Accuracy: 89.7%

パラメータを調整したりすると、また変わってくると思うが、LL-SVMでいい感じに分類できることがわかる。

アルゴリズムの説明

LL-SVMについてザックリ説明する。LL-SVMの発想としては、多様体学習のLocally Linear Embeddingと似ていて、局所的な線形SVMをなめらかにつなぎあわせることで、結果として非線形な決定境界を得ようというものである。N個のサンプル\mathbf{x}_{1},\ldots,\mathbf{x}_{N}があり、それぞれに二値のラベルy_1,\ldots,y_N\in\lbrace -1,1\rbraceが付与されているとする。LL-SVMのコスト関数は次のとおりである。

 \displaystyle
\mathrm{argmin}_{W,\mathbf{b}}\frac{\lambda}{2}||W||^2+\frac{1}{N} \sum_{k=1}^{N}\max(0, 1-y_k H_{W,\mathbf{b}}(\mathbf{x}_k))

ヒンジ損失によるSVMと似ているが、重みベクトルではなく重み行列Wとなっていて、重みベクトルが複数あることを示している。さらに肝心なのはHで、これは以下のように定義される。

 \displaystyle
H_{W,\mathbf{b}}(\mathbf{x}_k)=\gamma(\mathbf{x}_k)^{\top}W\mathbf{x}_k+\gamma(\mathbf{x}_k)^{\top}\mathbf{b}

\gamma(\mathbf{x})は、local codingと呼ばれるもので、\mathbf{x}を、その近傍に位置する代表点\mathbf{v}の線形結合で近似する際の係数である。

 \displaystyle
\mathbf{x} \approx \sum_{\mathbf{v}\in C}\gamma_{\mathbf{v}}(\mathbf{x})\mathbf{v}

重みとバイアスを、係数\gammaにより線形結合したものを、\mathbf{w}_\gamma^{\top}=\gamma(\mathbf{x}_k)^{\top}Wb_\gamma=\gamma(\mathbf{x}_k)^{\top}\mathbf{b}とすると、線形SVMによる識別関数と同様になる。

 \displaystyle
H_{W,\mathbf{b}}(\mathbf{x}_k)=\mathbf{w}_\gamma^{\top}\mathbf{x}+b_\gamma

こうして、代表点ごとに学習した線形SVMが、local codingによりつなぎあわせられる。代表点には、k-means法によるセントロイドが用いられる。

LL-SVMの重要なパラメータとしては、代表点数とlocal codingのための近傍数となる。よくある2-moonsデータで、代表点数と近傍数による決定境界の違いを見てみる。

代表点16個で近傍数8 - 半円の端が誤分類されてしまう

代表点16個で近傍数4 - 近傍数を小さくすることである程度データ分布に沿った決定境界を得られている

代表点128個で近傍数8 - データ分布を捉えることができていて決定境界もなめらかになっている

論文では、確率的勾配降下法により重み行列を求める方法が提案されている。ただ、確率的勾配降下法は、それなりのデータ量とイテレーション回数、また学習率の調整が必要で、いい感じの結果を得るのが難しい。また、Rubyで実装するとなると、timesやeachによるループが必要になるので、実行速度が遅くなる。そこで、二乗ヒンジ損失にして、L-BFGS法で最適化する方法で実装している。

 \displaystyle
J(W,\mathbf{b})=\frac{\lambda}{2}||W||^2+\frac{1}{|S|} \sum_{k\in S}\max(0, 1-y_k H_{W,\mathbf{b}}(\mathbf{x}_k))^{2}

 \displaystyle
\frac{\partial J}{\partial W}=\lambda W-\frac{2}{|S|} \sum_{k\in S}\max(0, 1-y_k H_{W,\mathbf{b}}(\mathbf{x}_k))y_k \gamma(\mathbf{x}_k)\mathbf{x}_k^{\top}

おわりに

深層学習が牛耳っている様な時代に、10年以上も前のSVMを実装したのは、Google Scholarで探したりすると、最近でもSVMに関する論文がチラホラ発表されていて、懐かしい気持ちになったからである。ニューラルネットワークが冬の時代から復活したように、今は熱心に研究されていない機械学習の手法も、効果的な学習方法が発見されて大躍進をとげる...かもしれない。

github.com

LINE社の日本語言語モデルは前提知識をもとにした回答ができる

はじめに

Hugging FaceにあるLINE社の日本語言語モデルの実行例では、四国の県名をたずねるシンプルな受け答えのみなので、凝ったことができるか試してみた。結果、前提知識をもとに回答できることがわかった。

実験

準備

LINE社の日本語言語モデルを、GPTNeoXClientを利用して叩く。言語モデルの取得・変換、GPTNeoXClientのインストールは、以下の記事を参考にしてください。

yoshoku.hatenablog.com

比較実験

土方歳三の誕生日と職業を聞いてみる。

require 'gpt_neox_client'

client = GPTNeoXClient.new(path: '/path/to/ggml-model-f16.bin', seed: 123_456_789, n_threads: 8)

puts client.completions(
  'ユーザー:土方歳三の誕生日と職業を教えてください。<0x0A>システム:',
  top_p: 0.8,
  top_k: 5,
  temperature: 0.7,
  repeat_penalty: 1.1
).gsub('<0x0A>', "\n").gsub('</s>', '')

これを実行すると...

1850年2月6日
彼は、新選組の創設者である沖田総司の兄である土方歳三です。

パラメータでまた変わってくると思うが見事にデタラメだ。沖田総司の兄ではない...この問題を解決するため、プロンプトで、Wikipediaにある情報を前提知識として与えてみる。

require 'gpt_neox_client'

client = GPTNeoXClient.new(path: '/path/to/ggml-model-f16.bin', seed: 123_456_789, n_threads: 8)

prompt = <<~PROMPT
  コンテキスト情報は以下の通りです。
  ---------------------
  土方歳三は、1835年5月31日に生まれ、1869年6月20日に死去しました。幕末期の幕臣で、新選組副長を務めました。家紋は左三つ巴です。
  ---------------------
  元々の知識はなく、コンテキスト情報のみを与えられた状態で、以下の質問に答えてください:
  ユーザー:土方歳三の誕生日と職業を教えてください。
  システム:
PROMPT
  .chomp.gsub("\n", '<0x0A>')

puts client.completions(
  prompt,
  top_p: 0.8,
  top_k: 5,
  temperature: 0.7,
  repeat_penalty: 1.1
).gsub('<0x0A>', "\n").gsub('</s>', '')

これを実行すると...

土方歳三は、1835年5月31日に生まれ、新選組副長として活躍しました。

与えた前提知識をもとに回答してくれた。事実を回答させたい場合は、これでイケる。

おわりに

LINE社の日本語言語モデルでも、いわゆるプロンプトエンジニアリングと呼ばれることができそうだ。ローカルで好き勝手遊べる言語モデルは楽しいですね。

LINE社の日本語言語モデルをRubyで試す

はじめに

LINE社の日本語言語モデルを手元のmacOSで試したくて、llama.cppのRuby bindingsを作ってる経験を活かして簡単なGPT-NeoXモデルのクライアントを作った。

github.com

モデルの準備

LINE社の言語モデルをggml形式に変換する。ggmlをcloneしてきて、変換用のPythonスクリプトを動かす。自分の環境だけかもしれないが、この際、requirements.txtのものをインストールするだけでなく、protobuf v3.20.0も必要だった。

$ git clone https://github.com/ggerganov/ggml.git
$ cd ggml
$ pip install -U protobuf~=3.20.0
$ python -m pip install -r requirements.txt

LINE社の言語モデルはHugging Faceから取得できる。最終的にチャット的なことを試したいので、instruction tuningしたモデルを取得した。

$ git lfs install
$ git clone https://huggingface.co/line-corporation/japanese-large-lm-3.6b-instruction-sft

取得したモデルに対して変換スクリプトを実行する。ggml-model-f16.binというファイルが、ggml形式に変換したモデルになる。

$ python examples/gpt-neox/convert-h5-to-ggml.py japanese-large-lm-3.6b-instruction-sft 1
$ ls japanese-large-lm-3.6b-instruction-sft/ggml-model-f16.bin
japanese-large-lm-3.6b-instruction-sft/ggml-model-f16.bin

IRBで試す

LINE社の言語モデルはGPT-NeoXと呼ばれるものを利用している(日本語言語モデルのRinnaもそう)。 ggmlのexamplesのなかに、GPT-NeoXなモデルを読み込んで補間するものがあり、これのRuby bindingsを作った。それがGPTNeoXClientである。 gemコマンドでインストールできる。

$ gem install gpt_neox_client

もろもろ準備できたので、IRBで簡単に試す。 Hugging Faceのリポジトリにある四国の県名をたずねる例を試してみた。基本CPUで動くので、結果の出力までに時間はかかる。

irb(main):001:0> require 'gpt_neox_client'
=> true
irb(main):002:0> client = GPTNeoXClient.new(path: '/path/to/japanese-large-lm-3.6b-instruction-sft/ggml-model-f16.bin', seed: 123_456_789, n_threads: 8)
gpt_neox_model_load: loading model from '/path/to/japanese-large-lm-3.6b-instruction-sft/ggml-model-f16.bin' - please wait ...
gpt_neox_model_load: n_vocab = 51200
gpt_neox_model_load: n_ctx   = 2048
gpt_neox_model_load: n_embd  = 3072
gpt_neox_model_load: n_head  = 32
gpt_neox_model_load: n_layer = 30
gpt_neox_model_load: n_rot   = 96
gpt_neox_model_load: par_res = 0
gpt_neox_model_load: ftype   = 1
gpt_neox_model_load: qntvr   = 0
gpt_neox_model_load: ggml ctx size = 9604.72 MB
gpt_neox_model_load: memory_size =   720.00 MB, n_mem = 61440
gpt_neox_model_load: ............................................. done
gpt_neox_model_load: model size =  7084.59 MB / num tensors = 364
=>
#<GPTNeoXClient:0x00000001081e38f0
...
irb(main):003:1* client.completions(
irb(main):004:1*   'ユーザー:四国の県名を全て列挙してください。<0x0A>システム:',
irb(main):005:1*   top_p: 0.9,
irb(main):006:1*   top_k: 1,
irb(main):007:1*   temperature: 0.7
irb(main):008:0> )
=> "ユーザー:四国の県名を全て列挙してください。<0x0A>システム:徳島県、香川県、愛媛県、高知県</s>"

チャットな感じにする

動作が確認できたので、チャット形式でやりとりできるようにする。readlineで入力を受け付けて、それをcompletionsメソッドに渡すだけで、それらしいものができる。出力にある「<0x0A>」は改行を表し「」は終端を表すので、それを置換してる。

require 'gpt_neox_client'
require 'readline'

MODEL_PATH = '/path/to/japanese-large-lm-3.6b-instruction-sft/ggml-model-f16.bin'

client = GPTNeoXClient.new(path: MODEL_PATH, seed: 123_456_789, n_threads: 8)

puts '---'

while (buf = Readline.readline('ユーザー: ', true))
  prompt = "ユーザー:#{buf}<0x0A>システム:"
  result = client.completions(
    prompt,
    top_p: 0.9,
    top_k: 1,
    temperature: 0.7
  ).gsub(prompt, '').gsub('<0x0A>', "\n").gsub('</s>', '')
  puts "システム: #{result}"
end

実行して、人生を聞いてみた。

$ ruby chat.rb
gpt_neox_model_load: loading model from '/path/to/japanese-large-lm-3.6b-instruction-sft/ggml-model-f16.bin' - please wait ...
gpt_neox_model_load: n_vocab = 51200
gpt_neox_model_load: n_ctx   = 2048
gpt_neox_model_load: n_embd  = 3072
gpt_neox_model_load: n_head  = 32
gpt_neox_model_load: n_layer = 30
gpt_neox_model_load: n_rot   = 96
gpt_neox_model_load: par_res = 0
gpt_neox_model_load: ftype   = 1
gpt_neox_model_load: qntvr   = 0
gpt_neox_model_load: ggml ctx size = 9604.72 MB
gpt_neox_model_load: memory_size =   720.00 MB, n_mem = 61440
gpt_neox_model_load: ............................................. done
gpt_neox_model_load: model size =  7084.59 MB / num tensors = 364
---
ユーザー: よりよい人生を送る方法を教えてください。
システム: より良い人生を生きる方法は次のとおりです。

1.あなたのニーズと欲求に優先順位を付けます。
2.あなたのために働くためにあなたのライフスタイルを適応させます。
3.あなたの健康と幸福を優先します。
4.あなたの家族、友人、コミュニティとの前向きな関係を維持します。
5.あなたの価値観と優先順位に従って決定を下します。
6.あなたの目標と目的に集中してください。
7.あなたの目標と目的が達成されるまで、あなたのプロセスを継続的に改善します。

これらの7つのステップに従うことで、あなたはより良い人生を生きることができます。
ユーザー:

エッセンシャル思考とエフォートレス思考をまとめた様なことが出力された。いい感じですね。

おわりに

ggml形式なGPT-NeoXモデルのRubyクライアントを作って、LINE社の日本語言語モデルを試してみた。本当はRailsでいい感じのデモ作れるとカッコいいんでしょうけど、ここまでで満足してしまった。GPTNeoXClientは、シンプルなクライアントで、ggml形式のGPT-NeoXモデルの読み込みと補間しかできない。気が向いたら改良します。ちなみに、Windowsは、GitHub Actions上ではggmlのコードのコンパイルでコケたので、たぶん動かないです。

LINE社の言語モデルは、ライセンスがApache License 2.0なのが、太っ腹で最高です!!日本の大規模言語モデルの発展に寄与するぞ、という気概が伝わってきます。

Numo::LinalgのサブセットのNumo::TinyLinalgを作った

はじめに

Numo::NArrayで逆行列計算や固有値分解などの線形代数計算を担うNumo::LinalgのサブセットであるNumo::TinyLinalgを少しずつ作っていた。どのようなサブセットかというと、機械学習で使う(Rumaleで使う)メソッドだけを実装したものになる。

github.com

インストール

Numo::Linalgは、BLAS/LAPACKのためのライブラリを選択できる方式をとっているが、Numo::TinyLinalgではOpenBLASだけに対応している。必要に応じて、事前にOpenBLASをインストールする。

$ brew install openblas

その上で、OpenBLASのインストールディレクトリを指定してインストールする(/usr/libとか一般的なディレクトリにインストールしてあるならインストールディレクトリの指定は必要ない)。

$ gem install numo-tiny_linalg -- --with-opt-dir=/opt/homebrew/Cellar/openblas/0.3.23/

インストールでnative extensionsのビルド時に、OpenBLASが見つからない場合は、OpenBLASをダウンロードしてビルドするようにも作ってあるので、なにもせずgem installでもよい。

$ gem install numo-tiny_linalg

使い方

サブセットなのでNumo::Linalgと同様になる。Numo::NArrayは、Numo::Linalgがロードされている場合、実数配列同士のdotメソッドでNumo::Linalgのdotメソッドを呼ぶようにできている。これを再現するために、Numo::TinyLinalgでもdotメソッドを実装してある。なので、以下のように、Numo::Linalgがロードされていない場合に、Numo::TinyLinalgで置き換えることができる。

require 'numo/tiny_linalg'

Numo::Linalg = Numo::TinyLinalg unless defined?(Numo::Linalg)

あとはNumo::Linalgと同様となる。例えば、対称行列の固有値分解は以下のようになる。

x = Numo::DFloat.new(5, 3).rand - 0.5
c = x.dot(x.transpose)

vals, vecs = Numo::TinyLinalg.eigh(c, vals_range: [2, 4])

pp vals
# Numo::DFloat#shape=[3]
# [0.118795, 0.434252, 0.903245]

pp vecs
# Numo::DFloat#shape=[5,3]
# [[0.154178, 0.60661, -0.382961],
#  [-0.349761, -0.141726, -0.513178],
#  [0.739633, -0.468202, 0.105933],
#  [0.0519655, -0.471436, -0.701507],
#  [-0.551488, -0.412883, 0.294371]]

pp (c - vecs.dot(vals.diag).dot(vecs.transpose)).abs.max
# 3.3306690738754696e-16

おわりに

開発のきっかけはNumo::Linalgの開発が長らく停止しているように見えることにある。OSSなので停止していることは全く問題ない(私自身が腱鞘炎やら多忙やらで2年くらい大した活動ができなくなったことがあり、開発者に様々な事情があるのは理解できるし、OSSのために人生を捧げる必要はないと考える)。ただ、今後のRumaleの拡張を考えると、Numo::Linalgの発展が必要になったので、サブセットを作ることにした。forkすることも考えたが、Numo::Linalgは(Numo::NArrayも)erbによりC言語でGeneric programmingを実現していて、これ自体は大発明なのだが、常にコードに触れていないと概念というか開発のコツを忘れてしまう。(再びヒドい腱鞘炎になるとか)開発が断続的になってもスムーズに再開できるように、C++でサブセットを作ることにした。

今後はRumaleなど、機械学習ライブラリの開発に戻り、また必要に応じて拡張したいと思う。おそらく、ガウス過程回帰を実装するとかで、choloeskyを追加すると思う。

llama_cpp.rbを使ってRubyでLlama2を利用する

概要

llama.cppのRuby bindingsであるllama_cpp.rbでもLlama2が使えた。

github.com

インストール

llama_cpp.rbのインストールは、通常のnative extensionsなgemと同様である。

$ gem install llama_cpp

もしmacOSを利用しているなら、metalオプションをつけると少し速くなる。

$ gem install llama_cpp -- --with-metal

Llama2のダウンロード

Llama2対応については、本家llama.cppのIssueでも話題になっていて、有志によりllama.cppで読み込めるggmlモデル化および量子化されたものがある。これをダウロードして利用した。

Add llama 2 model · Issue #2262 · ggerganov/llama.cpp · GitHub

$ wget https://huggingface.co/TheBloke/Llama-2-7B-GGML/resolve/main/llama-2-7b.ggmlv3.q4_0.bin

使用方法

Llama2の量子化モデルを読み込むことができ、テキスト生成も問題なく動いた。

require 'llama_cpp'

params = LLaMACpp::ContextParams.new
model = LLaMACpp::Model.new(model_path: 'llama-2-7b.ggmlv3.q4_0.bin', params: params)
context = LLaMACpp::Context.new(model: model)

puts LLaMACpp.generate(context, 'Hello, World.')

Llama2は日本語もイケるようなので、チャットを日本語で試してみる。

$ wget https://raw.githubusercontent.com/yoshoku/llama_cpp.rb/main/examples/chat.rb
$ wget https://raw.githubusercontent.com/yoshoku/llama_cpp.rb/main/examples/prompt_jp.txt
$ ruby chat.rb --model llama-2-7b.ggmlv3.q4_0.bin --file prompt_jp.txt

日本語で回答してくれた。本当に日本の家電最大メーカーが三菱かどうかは知らない。

おわりに

GPT-3.5に匹敵するとされるLlama2の登場で、ローカルでの大規模言語モデル(Large Language Model, LLM)の利用の幅が広がりましたね。