Pythonで作成したRandomForestモデルをjsonにしてGoで動かす

## 問題・経緯
- Scikit learnで作成したRandomForestモデルをGoで動かしたい件があったのでjsonで出力してGoでロードして推論した
- 同じ入力で推論テストを行うと、Pythonでの結果とGoでの結果が異なるサンプルがあった
- 原因究明のため、テストのデータセットを作って検証した

## 推論結果が一致しなかったコード（失敗）
- 結果比較
    ```bash
    go_label vs python_label      : 75 / 100 correct (75.00%)
    ```
### Python
- Scikit learnでRandom Forestによる`label`の予測モデルを作成
- json形式でモデルを保存（`rf_model.json`）
- 以下、コードの抜粋
    ```python
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.model_selection import train_test_split
    
    df = pd.read_csv('/data/input.csv')
    
    X = df[['magnetic_field', 'electric_field', 'position', 'momentum']]
    y = df['label']
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

    clf = RandomForestClassifier(n_estimators=3, max_depth=2, random_state=42)
    clf.fit(X_train, y_train)

    model_json = []
    for estimator in clf.estimators_:
        tree = estimator.tree_
        model_json.append({
            'children_left': tree.children_left.tolist(),
            'children_right': tree.children_right.tolist(),
            'feature': tree.feature.tolist(),
            'threshold': tree.threshold.tolist(),
            'value': tree.value[:, 0, :].tolist(),
        })
    
    with open('/data/rf_model.json', 'w') as f:
        json.dump(model_json, f)
    
    ```
### Go
- 多数決投票を使用
- 以下、コードの抜粋
    ```Go
    // Original prediction (unscaled) - using unfixed majority voting
	goLabel, err := predictForestUnfixed(forest, sample)
        
    // Unfixed process: Majority voting from hard predictions (might differ from Python)
    func predictForestUnfixed(forest []Tree, sample []float64) (int, error) {
    	// Count votes from each tree
    	votes := make(map[int]int)
    	
    	for i, tree := range forest {
    		probabilities, err := predictTree(tree, sample)
    		if err != nil {
    			return 0, fmt.Errorf("error in tree %d: %v", i, err)
    		}
    		
    		// Find class with highest probability for this tree
    		maxProb := 0.0
    		maxIdx := 0
    		for j, prob := range probabilities {
    			if prob > maxProb {
    				maxProb = prob
    				maxIdx = j
    			}
    		}
    		
    		votes[maxIdx]++
    	}
    	
    	// Find class with most votes
    	maxVotes := 0
    	prediction := 0
    	for class, vote := range votes {
    		if vote > maxVotes {
    			maxVotes = vote
    			prediction = class
    		}
    	}
    	
    	return prediction, nil
    }
    
    func predictTree(tree Tree, sample []float64) ([]float64, error) {
    	node := 0
    	for tree.ChildrenLeft[node] != -1 {
    		feature := tree.Feature[node]
    		if feature >= len(sample) {
    			return nil, fmt.Errorf("feature index %d out of range (sample length: %d)", feature, len(sample))
    		}
    		if sample[feature] <= tree.Threshold[node] {
    			node = tree.ChildrenLeft[node]
    		} else {
    			node = tree.ChildrenRight[node]
    		}
    	}
    	// Return the probability distribution at the leaf node
    	// sklearn stores values as [[class0_count, class1_count, ...]]
    	// We need to normalize these to get probabilities
    	values := tree.Value[node] // Get the values at this node
    	total := 0.0
    	for _, v := range values {
    		total += v
    	}
    	
    	if total == 0 {
    		return nil, fmt.Errorf("no samples in leaf node")
    	}
    	
    	probabilities := make([]float64, len(values))
    	for i, v := range values {
    		probabilities[i] = v / total
    	}
    	return probabilities, nil
    }
    
    ```


## 推論結果が一致したコード（成功）
- 結果比較
    ```
    ✅ fixed_go_label vs python_label: 100 / 100 correct (100.00%)
    ```
### Python
- Scikit learnでRandom Forestによる`label`の予測モデルを作成
- **学習データとテストデータをZ-score normalization（平均0、標準偏差1にスケーリング）**
- json形式でモデルを保存（`rf_model.json`）
- **json形式でスケーリングパラメータを保存（`scaler.json`）**
- 以下、コードの抜粋
    ```python
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.model_selection import train_test_split
    from sklearn.preprocessing import StandardScaler

    df = pd.read_csv('/data/input.csv')
    
    X = df[['magnetic_field', 'electric_field', 'position', 'momentum']]
    y = df['label']
    
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
    
    scaler = StandardScaler()
    X_train_scaled = scaler.fit_transform(X_train)
    X_test_scaled = scaler.transform(X_test)
    
    # Save scaler parameters
    scaler_params = {
        "mean": scaler.mean_.tolist(),
        "scale": scaler.scale_.tolist()
    }
    with open('/data/scaler.json', 'w') as f:
        json.dump(scaler_params, f)
    
    clf = RandomForestClassifier(n_estimators=3, max_depth=2, random_state=42)
    clf.fit(X_train_scaled, y_train)
    
    model_json = []
    for estimator in clf.estimators_:
        tree = estimator.tree_
        model_json.append({
            'children_left': tree.children_left.tolist(),
            'children_right': tree.children_right.tolist(),
            'feature': tree.feature.tolist(),
            'threshold': tree.threshold.tolist(),
            'value': tree.value[:, 0, :].tolist(),
        })
    
    with open('/data/rf_model.json', 'w') as f:
        json.dump(model_json, f)
    ```

### Go
- **スケーリングパラメータで入力値をスケーリング**
- **確率平均化を使用**
- 以下、コードの抜粋
    ```Go
    // Scaled prediction (compensated) - using fixed probability averaging
    scaledSample := applyStandardScaler(sample, scaler)
	fixedLabel, err := predictForestFixed(forest, scaledSample)

    func applyStandardScaler(sample []float64, params ScalerParams) []float64 {
    	scaled := make([]float64, len(sample))
    	for i := range sample {
    		scaled[i] = (sample[i] - params.Mean[i]) / params.Scale[i]
    	}
    	return scaled
    }    
        
    // Fixed process: Probability averaging to match Python sklearn behavior
    func predictForestFixed(forest []Tree, sample []float64) (int, error) {
    	if len(forest) == 0 {
    		return 0, fmt.Errorf("empty forest")
    	}
    	
    	// Get first tree probabilities to determine number of classes
    	firstProbs, err := predictTree(forest[0], sample)
    	if err != nil {
    		return 0, fmt.Errorf("error getting first tree probabilities: %v", err)
    	}
    	
    	numClasses := len(firstProbs)
    	classProbabilities := make([]float64, numClasses)
    	
    	// Average probabilities from all trees (sklearn approach)
    	for i, tree := range forest {
    		treeProbabilities, err := predictTree(tree, sample)
    		if err != nil {
    			return 0, fmt.Errorf("error in tree %d: %v", i, err)
    		}
    		
    		if len(treeProbabilities) != numClasses {
    			return 0, fmt.Errorf("tree %d has %d classes, expected %d", 
    				i, len(treeProbabilities), numClasses)
    		}
    		
    		for j, prob := range treeProbabilities {
    			classProbabilities[j] += prob
    		}
    	}
    	
    	// Average across all trees
    	for i := range classProbabilities {
    		classProbabilities[i] /= float64(len(forest))
    	}
    	
    	// Return class with highest average probability
    	maxIdx := 0
    	maxProb := classProbabilities[0]
    	for i := 1; i < len(classProbabilities); i++ {
    		if classProbabilities[i] > maxProb {
    			maxProb = classProbabilities[i]
    			maxIdx = i
    		}
    	}
    	
    	return maxIdx, nil
    }

    ```

## 問題の解決策
### Scikit learnのランダムフォレスト分類期の実装
scikit-learnの実装では、各分類器が単一のクラスに投票する方式（Majority vote）ではなく、確率予測を平均化することによって分類器を組み合わせているため、Goでもそのように計算する必要があった。

> In contrast to the original publication [B2001], the scikit-learn implementation combines classifiers by averaging their probabilistic prediction, instead of letting each classifier vote for a single class.
[B2001]
Breiman, “Random Forests”, Machine Learning, 45(1), 5-32, 2001.
[1.11.2.1. Random Forests](https://scikit-learn.org/stable/modules/ensemble.html#random-forests)

### Z-score normalizationの適用
説明変数をモデルへ入力する前にZ-score normalizationを適用すると、結果が合った（適用しない場合、一致率が99%になる場合あり）。
検証時、PythonでもGoでもfloat64型で計算しているところまで確認したが、なぜZ-score normalizationで結果が一致したか不明。


---

今回の検証で使ったプログラムは下記のリポジトリに共有しています。

https://github.com/hayato-akagi/randomforest_test