CRFsuite の正則化パラメータ最適化

例えば CRFsuite で l2sgd を使う場合、正則化パラメータとして c2 を指定できます。デフォルトは 1 です。

% crfsuite learn -H -a l2sgd
CRFSuite 0.12.2  Copyright (c) 2007-2013 Naoaki Okazaki

PARAMETERS for l2sgd (crf1d):

float feature.minfreq = 0.000000;
The minimum frequency of features.

int feature.possible_states = 0;
Force to generate possible state features.

int feature.possible_transitions = 0;
Force to generate possible transition features.

float c2 = 1.000000;
Coefficient for L2 regularization.

int max_iterations = 1000;
The maximum number of iterations (epochs) for SGD optimization.

(snip)

正則化パラメータは小さすぎると過学習するし、大きすぎると精度が悪くなるので、グリッドサーチなどで適切な値を選ぶことが多いかと思います。1

というわけで、グリッドサーチするための Ruby のクラスを書いてみたという話です。

コマンドラインで頑張ってみる

crfsuite コマンドには cross validation 用に -x オプションがあるので、分割数を -g オプションで指定することで、各試行の結果がわかります。
なので、以下のように c2 の値を変えて何度も実行することで、最適な値を確認することができます。

% for c2 in 0.001 0.01; do
for> crfsuite learn -a l2sgd -p c2=$c2 -g3 -x /path/to/input
for> done

が、目視で確認するのは辛すぎるので出力結果をパースするスクリプトが必須と思われます。

Ruby で頑張る

というわけで、本題です。次のコードは grid search するクラスの定義です。
crfsuite gem のインストールや、この後使う train.crfsuite.txt の作成については「CRFsuite を Ruby から実行してみた」を参照してください。

# Copyright 2015- Takeshi Arabiki
# License: MIT License (http://opensource.org/licenses/MIT)

require 'crfsuite'
require 'tempfile'

module Crfsuite
  class Parameter
    def initialize(name, algorithm_name, algorithm_value)
      @name = name
      @trainer = Trainer.new
      @trainer.select(algorithm_name, algorithm_value)
    end

    def grid_search(xseqs, yseqs, param_values, cv: 10)
      dataset = xseqs.zip(yseqs)

      scores_by_param_value = Hash.new{ |h, k| h[k] = [] }
      n = (dataset.size / cv.to_f).ceil
      dataset_groups = dataset.each_slice(n).to_a
      dataset_groups.size.times do |i|
        @trainer.clear

        groups = dataset_groups.dup
        test_data = groups.delete_at(i)
        groups.each do |group|
          group.each do |xseq, yseq|
            @trainer.append(xseq, yseq, 0)
          end
        end

        param_values.each do |param_value|
          @trainer.set(@name, param_value.to_s)
          @trainer.train(temp_model_file, -1)

          tagger = Crfsuite::Tagger.new
          tagger.open(temp_model_file)
          correct_count = test_data.count do |xseq, yseq|
            tagger.set(xseq)
            predicted_yseq = tagger.viterbi
            predicted_yseq.to_a == yseq.to_a
          end
          scores_by_param_value[param_value] << correct_count.to_f / test_data.size
        end
      end

      scores_by_param_value.map do |param_value, scores|
        GridScore.new(param_value, scores)
      end
    end

    private

    def temp_model_file
      @temp_model_file ||= Tempfile.new('model').tap { |f|
        f.close
      }.path
    end

    class GridScore
      include Comparable

      attr_reader :param_value, :scores

      def initialize(param_value, scores)
        @param_value = param_value
        @scores = scores
      end

      def mean_score
        @mean_score ||= scores.reduce(&:+) / scores.size
      end

      def <=>(other)
        mean_score <=> other.mean_score
      end
    end
  end
end

次のように使います。instances の定義はこれです

require 'crfsuite'
require 'crfsuite/parameter'

trainer = Crfsuite::Trainer.new

xseqs = []
yseqs = []
# Read training instances from STDIN, and set them to trainer.
instances($stdin).each do |xseq, yseq|
  xseqs << xseq
  yseqs << yseq
end

parameter = Crfsuite::Parameter.new('c2', 'l2sgd', 'crf1d')
grid_scores = parameter.grid_search(xseqs, yseqs, [0.001, 0.01, 0.1, 1, 10], cv: 3)
grid_scores.each do |score|
  scores = score.scores.map{ |s| s.round(3) }
  mean_score = score.mean_score.round(3)
  puts "param_value: #{score.param_value}, mean_score: #{mean_score}, scores: #{scores}"
end
puts "Best param value: #{grid_scores.max.param_value}"

実行してみます。

% ruby -I. sample_train.rb < train.crfsuite.txt
param_value: 0.001, mean_score: 0.572, scores: [0.54, 0.602, 0.574]
param_value: 0.01, mean_score: 0.573, scores: [0.543, 0.602, 0.574]
param_value: 0.1, mean_score: 0.573, scores: [0.543, 0.602, 0.574]
param_value: 1, mean_score: 0.564, scores: [0.533, 0.599, 0.559]
param_value: 10, mean_score: 0.497, scores: [0.47, 0.534, 0.485]
Best param value: 0.1

出力結果から、c2 の値は 0.1 が良いということがわかりますね。なんとなく 0.01 と 0.1 の間にありそうなので、この間で再度 grid search を行うとより良い値が見つかりそうです。