phpによるベイジアン

2年ぐらい前に作ったphpで作ったベイジアンクラス(ベイズ/ライブラリ)を休日にスクラッチから書き直したので公開してみる。

ライセンス:ご自由にお使いください。
大切なこと:バグがあっても泣かない。

<?
	//myベイズ ライブラリ
	class MyBayes
	{
		var $D = array();
		var $ALL = array();
		/*
		array(
		'カテゴリ名'		=> array('e' => このカテゴリに含まれる数
							,  'w' => array("単語1" => このカテゴリーのこの単語が表れた数
										,	"単語2" => このカテゴリーのこの単語が表れた数
											)
							) 
		);
		ALL				=> array('e' => すべてのカテゴリに登場した数
							,  'w' => array("単語1" => この単語がすべてのカテゴリーで登場した数
										,	"単語2" => この単語がすべてのカテゴリーで登場した数
											)
							) 
		*/
		var $LastWordCount = array();
		
		function MyBayes()
		{
			//総計を取るカテゴリーは絶対に存在していないと困る.
			$this->ALL = array('e'=>0,'w'=>array());
		}
		
		function Train($category , $wordsArray)
		{
			//新設したカテゴリ?
			if (!isset($this->D[$category]))
			{
				$this->D[$category] = array('e'=>0,'w'=>array());
			}
			//カテゴリに登場した件数を増やす.
			@$this->D[$category]['e'] ++;
			@$this->ALL['e'] ++;

			//単語が表れた回数を増やす.
			foreach($wordsArray as $word)
			{
				//単語が表れた回数を増やす.
				@$this->D[$category]['w'][$word]++;
				@$this->ALL['w'][$word]++;
			}
			return true;
		}
		
		function UnTrain($category , $wordsArray)
		{
			if (!isset($this->D[$category]))
			{
				return false;
			}
			//カテゴリに登場した件数を減らす.
			@$this->D[$category]['e'] --;
			@$this->ALL['e'] --;

			//単語が表れた回数を減らす.
			foreach($wordsArray as $word)
			{
				//単語が表れた回数を減らす.
				@$this->D[$category]['w'][$word]--;
				@$this->ALL['w'][$word]--;
			}
			return true;
		}

		//カテゴリ推測(ポールグラハムエンジン)
		function EngineByPaulGraham($wordsArray)
		{
			//個数を数えます.
			$wordCount = array();
			foreach($wordsArray as $word)
			{
				@$wordCount[$word]['count'] ++;
			}
			$scores = array();

			//単語毎に確率を計算します.
			foreach(array_keys($this->D) as $category)
			{
				foreach(array_keys($wordCount) as $word)
				{
					$gi = (double)@$this->ALL['w'][$word] - @$this->D[$category]['w'][$word];
					$bi = (double)@$this->D[$category]['w'][$word];

					$ngood = (double)@$this->ALL['e'] - @$this->D[$category]['e'];
					$nbad = (double)@$this->D[$category]['e'];
					if ($gi <= 0 && $bi <= 0)
					{
						$p = 0.5;
					}
					else
					{
						$p = ($bi / $nbad) /  ( ($gi / $ngood) + ($bi / $nbad) );
						// 0.01 <= $p <= 0.99 に収める.
						$p = min(max($p,0.01),0.99);
					}

					@$wordCount[$word][$category]['p'] = $p;
				}

				//このカテゴリである確立を求めます.
				//単語の確立を組み合わせて複合確立を求めます.
				$gp = $bp = 1;
				foreach(array_keys($wordCount) as $word)
				{
					$gp *= $wordCount[$word][$category]['p'];
					$bp *= (1 - $wordCount[$word][$category]['p']);
				}
				$scores[$category] = $gp / ($gp + $bp);
			}
			$this->LastWordCount = $wordCount;
			return $scores;
		}

		//カテゴリ推測(ポールグラハムエンジン改)
		//ポールグラハムエンジンの弱点としてまれにしか登場しない単語の確率が99%等にはねが上がってしまう問題がある。
		//そこで、マレにしか登場しないのに確率が高い単語があったら重しをつけて確立を下げる。(結構強引)
		function EngineByPaulGrahamKAI($wordsArray)
		{
			//出現頻度が低い単語につける重し
			$omoshiArray = array( 0=>0.25, 1=>0.20,2=>0.15,3=>0.10,4=>0.5 );
			$omoshiCount = 4;

			//個数を数えます.
			$wordCount = array();
			foreach($wordsArray as $word)
			{
				@$wordCount[$word]['count'] ++;
			}
			$scores = array();

			//単語毎に確率を計算します.
			foreach(array_keys($this->D) as $category)
			{
				foreach(array_keys($wordCount) as $word)
				{
					$gi = (double)@$this->ALL['w'][$word] - @$this->D[$category]['w'][$word];
					$bi = (double)@$this->D[$category]['w'][$word];

					$ngood = (double)@$this->ALL['e'] - @$this->D[$category]['e'];
					$nbad = (double)@$this->D[$category]['e'];
					if ($gi <= 0 && $bi <= 0)
					{
						$p = 0.5;
					}
					else
					{
						$p = ($bi / $nbad) /  ( ($gi / $ngood) + ($bi / $nbad) );

						//強引に重しをつける. この辺の数字は調整が必要!!
						if ($p >= 0.90  && $gi <= $omoshiCount)
						{
							$p = $p - $omoshiArray[$gi];
						}
						else if ($p <= 0.10  && $gi <= $omoshiCount)
						{
							$p = $p + $omoshiArray[$gi];
						}

						// 0.01 <= $p <= 0.99 に収める.
						$p = min(max($p,0.01),0.99);
					}

					@$wordCount[$word][$category]['p'] = $p;
				}

				//このカテゴリである確立を求めます.
				//単語の確立を組み合わせて複合確立を求めます.
				$gp = $bp = 1;
				foreach(array_keys($wordCount) as $word)
				{
					$gp *= $wordCount[$word][$category]['p'];
					$bp *= (1 - $wordCount[$word][$category]['p']);
				}
				$scores[$category] = $gp / ($gp + $bp);
			}

			$this->LastWordCount = $wordCount;
			return $scores;
		}
	};



	//ベイジアンのテスト
	function MyBTest()
	{
		global $L_SETTING;

		$spam  = "AAA AAA AAA BBB DDD";
		$ham   = "ZZZ ZZZ ZZZ BBB DDD";
		$banana= "BANANA";

		$b = new MyBayes();
		$b->Train('spam',explode(" ",$spam));
		$b->Train('ham',explode(" ",$ham));
		$b->Train('banana',explode(" ",$banana));

		$scores = $b->EngineByPaulGraham(explode(" ",$spam));
		assert(round( $scores['spam'] ,3) == 0.997);
		assert(round( $scores['ham'] ,3) == 0.039);
		assert(round( $scores['banana'] ,3) == 0);

		$scores = $b->EngineByPaulGraham(explode(" ",$ham));
		assert(round( $scores['spam'] ,3) == 0.039);
		assert(round( $scores['ham'] ,3) == 0.997);
		assert(round( $scores['banana'] ,3) == 0);

		$scores = $b->EngineByPaulGraham(explode(" ",'CCCA'));
		assert(round( $scores['spam'] ,3) == 0.5);
		assert(round( $scores['ham'] ,3) == 0.5);
		assert(round( $scores['banana'] ,3) == 0.5);

		$scores = $b->EngineByPaulGraham(explode(" ",'BANANA'));
		assert(round( $scores['spam'] ,3) == 0.01);
		assert(round( $scores['ham'] ,3) == 0.01);
		assert(round( $scores['banana'] ,3) == 0.99);

		//データ数が少ないと逆にあまりよくなくなるなぁ。。。
		$scores = $b->EngineByPaulGrahamKAI(explode(" ",$spam));
		assert(round( $scores['spam'] ,3) == 0.923);
		assert(round( $scores['ham'] ,3) == 0.308);
		assert(round( $scores['banana'] ,3) == 0.003);

	}
	set_time_limit(0);
	MyBTest();
	echo "OK";

?>

注意:ボールグラハムと名前が付いていますが、彼のアルゴリズムをガチで実装していません。出現頻度が低い単語を消していないのは手抜きというか、短い文章に対しての評価を目的としたので、こうしています。

EngineByPaulGrahamKAI は、ポールグラハムのアルゴリズムをrtiが強引に改造した邪悪なものです。
ポールグラハムのアルゴリズムでは、出現頻度が極端に偏った単語が 99%などの高確率になってしまうので、そーゆーヤツには強引に重しをつけて確立を沈めています。強引です。もっとスマートな方法がありそうですが、こまけーことはいいんだよって感じ?

現在データは、すべてオンメモリに確保しています。データを保存したい人はクラスごと serialize とかしてください。
形態素解析を行いたい人は、TinySegmenter for PHPとか使うといいんじゃないかなぁと思う。