0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コンピュータとオセロ対戦57 ~LoRAやってみるぞ~

0
Posted at

前回の記事

前回の記事が三年前と気づいてかなりショックを受けています.
そんなに経ったんだ…

今回の目標

LoRAでオセロAI作るぞ

ここから本編

前回までのあらすじ

深層強化学習でオセロAIを作ろうとしたが,すぐに勾配爆発してしまい全く学習が進みませんでした.
その後,色々試してみましたが全く原因が分からずただただ勾配爆発し続けます.
ということで,長く続いたこのオセロシリーズは3年もの間放置されてしまうことになってしまいました.

今回のあらすじ

この三年の間でAIもいろいろ変わりました.
ChatGPTが流行し,GeminiやClaudeが隆盛しました.
さらにはクラウド上で動くLLMだけでなく,ローカルで動くLLMも発展し,誰でも手軽にファインチューニングができるようになりました.

そして僕は思いついたのです.
ファインチューニングでオセロやったら面白いんじゃね?
この考えをClaudeに話してみたところ「切り口が面白いですね」と思いきりヨイショされたので,ガチで取り組んでみることにします.

方向性

といってもLLMによるオセロAIは(たぶん)前代未聞です.
このシリーズでも深さ優先探索や強化学習が主でしたからね.
まず考えられたのは,「今の盤面から次の一手を考える」です.しかしこれは論外.深さ優先探索と異なり「今の盤面」しか見ないので,どうせ弱いものしか作られないに決まっています.
という事で考えたのは,「エージェント型のオセロAI」.どういうことかというと,「この盤面でここに石を置いたらこうなるから…」と思考を言語化しながら検討していき,もっともよいと思われる場所に石を置くというアプローチ.
これをClaudeに提案したところ「面白いアイデアですね」とまたもヨイショしてもらったので,この方向性で進んでみることにします.

学習方法は,僕がこれまでのシリーズで作ってきた,深さ優先探索系のAIの思考を言語化して保存.
それを使ってLLMをファインチューニングして,オセロの思考を身に着けさせるという方針です.

データ集め用クラス

OseroBaseとOseroについては今までとほぼ同じなので省略.
今までとほぼ同じとはいっても三年前なんですけどね.

OseroBase.java
OseroBase.java
import java.util.Random;

/**
 * オセロの盤面管理クラス.
 * 盤面はビットボードで表現する(64bit整数 × 2: [0]=黒, [1]=白).
 * ほぼ Qiita 記事の実装に準拠 (https://qiita.com/tt_and_tk/items/22641b036d3b1e5fdbca)
 */
public class OseroBase {
    public static final int SIZE = 8;
    public static final int ROW_SHIFT = 3; // 行インデックスを列数(8)倍するためのビットシフト量 (2^3 = 8)
    protected static Random rand = new Random();

    protected long[] board = new long[2]; // board[0]=黒のビットボード, board[1]=白のビットボード
    protected boolean currentTurn = false; // false=黒, true=白

    /**
     * 盤面を初期配置に戻す.
     * board[0](黒)と board[1](白)をクリアし,オセロの初期4石を配置する.
     */
    public void initBoard() {
        this.board[0] = 0L;
        this.board[1] = 0L;
        // 初期4石: (3,3)白, (3,4)黒, (4,3)黒, (4,4)白
        this.board[1] |= 1L << ((3 << ROW_SHIFT) + 3); // (3,3) 白
        this.board[0] |= 1L << ((3 << ROW_SHIFT) + 4); // (3,4) 黒
        this.board[0] |= 1L << ((4 << ROW_SHIFT) + 3); // (4,3) 黒
        this.board[1] |= 1L << ((4 << ROW_SHIFT) + 4); // (4,4) 白
    }

    /**
     * 指定した手番が置ける場所があるか確認する.
     * @param board ビットボード(board[0]=黒, board[1]=白)
     * @param turn  手番(false=黒, true=白)
     * @return 置ける場所があれば true
     */
    public boolean canPlaceAny(long[] board, boolean turn) {
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                if (canPlace(row, col, board, turn)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * ビットボードのビット数(石の数)を数える.
     *
     * @param board カウント対象の盤面
     * @param turn  カウント対象のターン(board[0]=黒, board[1]=白)
     * @return セットされているビットの数
     */
    public int countStones(long[] board, boolean turn) {
        return Long.bitCount(board[turn ? 1 : 0]);
    }

    /**
     * 指定マスに石を置けるか判定する.
     * すでに石がある場合,または置いても相手石を1枚もはさめない場合は false を返す.
     *
     * @param row   行インデックス(0〜7)
     * @param col   列インデックス(0〜7)
     * @param board ビットボード(board[0]=黒, board[1]=白)
     * @param turn  手番(false=黒, true=白)
     * @return 合法手であれば true
     */
    public boolean canPlace(int row, int col, long[] board, boolean turn) {
        long place = 1L << ((row << ROW_SHIFT) + col);
        // すでに石がある場合は置けない
        if ((board[0] & place) != 0) return false;
        if ((board[1] & place) != 0) return false;

        int my  = turn ? 1 : 0;
        int opp = turn ? 0 : 1;

        int x = -1, y = -1;
        int focusRow, focusCol;
        // 八方向すべてチェックする
        for (int i = 0; i < 8; i++) {
            focusRow = row + x;
            focusCol = col + y;
            place = 1L << ((focusRow << ROW_SHIFT) + focusCol);

            // 隣に相手石が続き,その先に自分の石があれば置ける
            while ((board[opp] & place) != 0
                    && 0 <= x + focusRow && x + focusRow < SIZE
                    && 0 <= y + focusCol && y + focusCol < SIZE) {
                focusRow += x;
                focusCol += y;
                place = 1L << ((focusRow << ROW_SHIFT) + focusCol);
                if ((board[my] & place) != 0) return true;
            }

            // 次の方向へ
            y++;
            if      (y > 1)            { x++; y = -1; }
            else if (x == 0 && y == 0) { y++; }
        }

        // 八方向のどこにも置けない
        return false;
    }

    /**
     * 指定マスに石を置き,はさんだ相手石を反転させる.
     * 事前に canPlace() で合法手であることを確認してから呼ぶこと.
     *
     * @param row   行インデックス(0〜7)
     * @param col   列インデックス(0〜7)
     * @param board ビットボード(board[0]=黒, board[1]=白).このオブジェクトが直接更新される
     * @param turn  手番(false=黒, true=白)
     */
    public void place(int row, int col, long[] board, boolean turn) {
        int my  = turn ? 1 : 0;
        int opp = turn ? 0 : 1;

        int x = -1, y = -1;
        int focusRow, focusCol;
        long inver, place;
        board[my] |= 1L << ((row << ROW_SHIFT) + col); // 石を置く

        // 八方向に走査
        for (int i = 0; i < 8; i++) {
            inver    = 0;
            focusRow = row + x;
            focusCol = col + y;
            place    = 1L << ((focusRow << ROW_SHIFT) + focusCol);

            // 相手石を蓄積しながら進み,自分の石が見つかれば反転
            while ((board[opp] & place) != 0
                    && 0 <= x + focusRow && x + focusRow < SIZE
                    && 0 <= y + focusCol && y + focusCol < SIZE) {
                inver += place;
                focusRow += x;
                focusCol += y;
                place = 1L << ((focusRow << ROW_SHIFT) + focusCol);
                if ((board[my] & place) != 0) {
                    board[my]  += inver; // 反転した石を自分のボードに加算
                    board[opp] -= inver; // 反転した石を相手のボードから減算
                }
            }

            // 次の方向へ
            y++;
            if      (y > 1)            { x++; y = -1; }
            else if (x == 0 && y == 0) { y++; }
        }
    }
}
Osero.java
Osero.java
import java.io.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.regex.*;

/**
 * オセロAIクラス.
 * ほぼ Qiita 記事の実装に準拠 (https://qiita.com/tt_and_tk/items/22641b036d3b1e5fdbca)
 * 記事と異なる点: DataGen 用に thinkAndGetMove() メソッドを追加している.
 */
public class Osero extends OseroBase {
    /** 思考方法の識別子. */
    public enum PlayMethod {
        /** ランダムに置く */
        RANDOM,
        /** n手先で,自分の石数が最大になる位置に置く */
        N_HAND,
        /** n手先で,相手の取れる手数が最小になる位置に置く */
        N_LEAST,
        /** n手先で,自分の取れる手数が最大になる位置に置く */
        N_MOST,
        /** 人間が手を選ぶ */
        HUMAN,
        /** 学習済み LLM(Ollama 経由)が手を選ぶ */
        LLM,
    }

    /** 探索深さ.[0]=黒, [1]=白 */
    protected int[] readGoal = {3, 3};

    /** 黒と白の思考方法 */
    protected ArrayList<BiConsumer<long[], Boolean>> playMethod = new ArrayList<>();

    /** LLM プレイヤーが使用する Ollama モデル名 */
    protected static final String LLM_MODEL  = "qwen3.5-osero";
    /** Ollama API の URL */
    protected static final String OLLAMA_URL = "http://localhost:11434";
    /** 人間プレイヤー入力用 */
    protected static final Scanner scanner   = new Scanner(System.in);

    /**
     * 探索深さを設定する.
     * @param black 黒番の探索手数
     * @param white 白番の探索手数
     */
    public void setReadGoal(int black, int white) {
        this.readGoal[0] = black;
        this.readGoal[1] = white;
    }

    /**
     * 黒・白それぞれの思考方法を設定する.
     * @param black 黒番の思考方法
     * @param white 白番の思考方法
     */
    public void setPlayMethod(PlayMethod black, PlayMethod white) {
        this.playMethod.clear();
        this.playMethod.add(this.resolvePlayMethod(black));
        this.playMethod.add(this.resolvePlayMethod(white));
    }

    /**
     * PlayMethod enum を実際の思考メソッドに紐づく関数参照へ変換する.
     * setPlayMethod から呼ばれ,playMethod フィールドに格納する関数を作る.
     * @param algo 変換対象の思考方法
     * @return 指定した思考方法に対応する関数
     */
    protected BiConsumer<long[], Boolean> resolvePlayMethod(PlayMethod algo) {
        switch (algo) {
            case RANDOM:  return this::random;
            case N_HAND:  return this::nHand;
            case N_LEAST: return this::nLeast;
            case N_MOST:  return this::nMost;
            case HUMAN:   return this::human;
            case LLM:     return this::llm;
            default: throw new IllegalArgumentException("unknown algorithm: " + algo);
        }
    }

    // ── 盤面テキスト変換 ──────────────────────────────────────

    /**
     * 座標を "d3" 形式の文字列に変換する.
     * @param row 行インデックス(0〜7)
     * @param col 列インデックス(0〜7)
     * @return "a1"〜"h8" 形式の文字列
     */
    public String coordToStr(int row, int col) {
        return String.valueOf("abcdefgh".charAt(col)) + (row + 1);
    }

    /**
     * ビットボードをテキスト形式に変換する.
     * 盤面・手番・置ける位置の一覧を含む.
     * @param board ビットボード
     * @param turn  手番(false=黒, true=白)
     * @return 盤面テキスト
     */
    public String boardToText(long[] board, boolean turn) {
        StringBuilder sb = new StringBuilder();
        sb.append("  a b c d e f g h\n");
        for (int row = 0; row < SIZE; row++) {
            sb.append(row + 1).append(" ");
            for (int col = 0; col < SIZE; col++) {
                long bit = 1L << ((row << ROW_SHIFT) + col);
                if      ((board[0] & bit) != 0) sb.append("B ");
                else if ((board[1] & bit) != 0) sb.append("W ");
                else                             sb.append(". ");
            }
            sb.append("\n");
        }
        sb.append(turn ? "白" : "黒").append("の手番\n");
        List<String> validMoves = new ArrayList<>();
        for (int row = 0; row < SIZE; row++)
            for (int col = 0; col < SIZE; col++)
                if (canPlace(row, col, board, turn)) validMoves.add(coordToStr(row, col));
        sb.append("置ける位置: ").append(String.join(", ", validMoves));
        return sb.toString();
    }

    // ── AI 戦略メソッド ────────────────────────────────────────

    /**
     * ランダムに有効な手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void random(long[] board, boolean turn) {
        int row, col;
        do {
            row = OseroBase.rand.nextInt(SIZE);
            col = OseroBase.rand.nextInt(SIZE);
        } while (!canPlace(row, col, board, turn));
        place(row, col, board, turn);
    }

    /**
     * n手先の自分の石数が最大になる手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     *
     */
    public void nHand(long[] board, boolean turn) {
        this.exploreAssist(board, turn, this::exploreNHand);
    }

    /**
     * n手先の相手の手数が最小になる手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void nLeast(long[] board, boolean turn) {
        this.exploreAssist(board, turn, this::exploreNLeast);
    }

    /**
     * n手先の自分の手数が最大になる手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void nMost(long[] board, boolean turn) {
        this.exploreAssist(board, turn, this::exploreNMost);
    }

    // ── 人間・LLM プレイヤー ──────────────────────────────────

    /**
     * 人間が手を選ぶ.盤面を表示して標準入力から "d3" 形式で受け付ける.
     * 無効な入力は再入力を促す.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void human(long[] board, boolean turn) {
        while (true) {
            System.out.print("手を入力してください (例: d3): ");
            String input = scanner.nextLine().trim().toLowerCase();
            if (input.length() == 2) {
                int col = "abcdefgh".indexOf(input.charAt(0));
                int row  = input.charAt(1) - '1';
                if (col >= 0 && row >= 0 && row < SIZE && canPlace(row, col, board, turn)) {
                    place(row, col, board, turn);
                    return;
                }
            }
            System.out.println("無効な手です.有効な手を選んでください.");
        }
    }

    /**
     * Ollama 経由で学習済み LLM に手を問い合わせる.
     * 最大3回リトライし,有効な手が得られなければランダムにフォールバックする.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void llm(long[] board, boolean turn) {
        String prompt  = boardToText(board, turn);
        String escaped = escapeForJson(prompt);
        String reqBody = "{\"model\":\"" + LLM_MODEL + "\",\"stream\":false,"
                       + "\"messages\":[{\"role\":\"user\",\"content\":\"" + escaped + "\"}]}";

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create(OLLAMA_URL + "/api/chat"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(reqBody))
            .build();

        for (int attempt = 0; attempt < 3; attempt++) {
            try {
                HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
                // "content" フィールドを抽出
                Matcher m = Pattern.compile("\"content\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"")
                                   .matcher(resp.body());
                if (m.find()) {
                    String content = m.group(1).replace("\\n", "\n").replace("\\\"", "\"");
                    // </think> 以降から手の座標を探す
                    int thinkEnd  = content.indexOf("</think>");
                    String moveArea = thinkEnd >= 0 ? content.substring(thinkEnd + 8).trim() : content.trim();
                    Matcher mv = Pattern.compile("[a-h][1-8]").matcher(moveArea);
                    while (mv.find()) {
                        int col = "abcdefgh".indexOf(mv.group().charAt(0));
                        int row  = mv.group().charAt(1) - '1';
                        if (canPlace(row, col, board, turn)) {
                            place(row, col, board, turn);
                            return;
                        }
                    }
                }
            } catch (Exception e) {
                System.err.println("Ollama リクエストエラー: " + e.getMessage());
            }
        }
        // フォールバック: ランダムに打つ
        System.err.println("LLM から有効な手を取得できなかったため,ランダムに打ちます");
        random(board, turn);
    }

    /** JSON 文字列内の特殊文字をエスケープする(llm() のリクエスト組み立て用). */
    private String escapeForJson(String s) {
        return s.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r")
                .replace("\t", "\\t");
    }

    // ── 探索フレームワーク ────────────────────────────────────

    /** 盤面評価用インターフェース */
    @FunctionalInterface
    interface ScoreFunction {
        /**
         * ある盤面のスコアを計算するメソッド
         * @param board   今の盤面
         * @param nowTurn 評価時の手番(false=黒, true=白)
         * @param turn    盤面の手番(false=黒, true=白)
         * @param num     今評価している盤面が何手先のものか
         * @return        その盤面のスコア
         */
        double getScore(long[] board, boolean nowTurn, boolean turn, int num);
    }

    /**
     * 全候補手を評価し,最高スコアの手をランダムに選んで実際に置く.
     * DataGen ではこのメソッドを拡張した thinkAndGetMove() を使う.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     * @param func  ある盤面のスコアを計算するメソッド
     */
    public void exploreAssist(long[] board, boolean turn, ScoreFunction func) {
        double maxScore = -Double.MAX_VALUE;
        int[] rowAns = new int[SIZE * 2];
        int[] colAns = new int[SIZE * 2];
        int placeNum = 0;
        long[] boardLeaf = new long[2];

        // 全てのマスでループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 石を置けないマスは飛ばす
                if (!canPlace(row, col, board, turn)) continue;

                // 局面をコピーしてそこに石を置き,スコア計算
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);
                double score = func.getScore(boardLeaf, turn, !turn, 1);

                // 最大スコアとなる位置を記録しておく
                if (score > maxScore) {
                    maxScore  = score;
                    placeNum  = 0;
                    rowAns[0] = row; colAns[0] = col;
                } else if (score == maxScore) {
                    placeNum++;
                    rowAns[placeNum] = row; colAns[placeNum] = col;
                }
            }
        }

        // 同スコアの手が複数あればランダムに選ぶ
        if (placeNum > 0) {
            int idx = rand.nextInt(placeNum + 1);
            rowAns[0] = rowAns[idx]; colAns[0] = colAns[idx];
        }

        // 選んだ場所に実際に置く
        place(rowAns[0], colAns[0], board, turn);
    }

    // ── 再帰探索メソッド ──────────────────────────────────────

    /**
     * n手先の nowTurn の石数の平均を計算する(再帰).
     * @param board   今の盤面
     * @param nowTurn 評価基準となるプレイヤー(変化しない)
     * @param turn    現在手を打つプレイヤー
     * @param num     現在の探索手数
     * @return        nowTurn の石数の平均
     */
    public double exploreNHand(long[] board, boolean nowTurn, boolean turn, int num) {
        // 探索し切ったなら今の意思の数を返す
        if (num >= this.readGoal[nowTurn ? 1 : 0]) return countStones(board, nowTurn);

        double total = 0.0;
        int placeNum = 0;
        long[] boardLeaf = new long[2];

        // 全てのマスをループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 置けないならスキップ
                if (!canPlace(row, col, board, turn)) continue;

                // ボード状態をコピーしてコピー先に石を置く
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                // 置いた先を探索
                total += this.exploreNHand(boardLeaf, nowTurn, !turn, num + 1);
                placeNum++;
            }
        }

        // 現在の手番がパス。相手も置けなければゲーム終了(両者パス)
        if (placeNum == 0) {
            if (canPlaceAny(board, !turn)) return this.exploreNHand(board, nowTurn, !turn, num);
            return countStones(board, nowTurn);
        }

        // 全手の平均スコアを返す
        return total / placeNum;
    }

    /**
     * n手先(nowTurn の手番基準)の相手の手数を最小化する(再帰).
     * nowTurn の手番のときだけ depth をインクリメントする.
     * 符号を反転しながら平均を取ることで最大化を最小化に変換する.
     * @param board   今の盤面
     * @param nowTurn 評価基準となるプレイヤー(変化しない)
     * @param turn    現在手を打つプレイヤー
     * @param num     現在の探索手数
     * @return        nowTurn にとっての評価値
     */
    public double exploreNLeast(long[] board, boolean nowTurn, boolean turn, int num) {
        // 探索し切ったなら今の手数を返す
        if (num >= this.readGoal[nowTurn ? 1 : 0]) {
            // 相手(-nowTurn)の手数を負値で返す(相手の手数が多いほど評価としては低い)
            int mobility = 0;
            for (int row = 0; row < SIZE; row++)
                for (int col = 0; col < SIZE; col++)
                    if (canPlace(row, col, board, !nowTurn)) mobility++;
            return -(double) mobility;
        }

        double total = 0.0;
        int placeNum = 0;
        long[] boardLeaf = new long[2];
        // nowTurn の手番のときだけ depth を進める
        int nextNum = (nowTurn == turn) ? num + 1 : num;

        // 全てのマスをループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 置けないならスキップ
                if (!canPlace(row, col, board, turn)) continue;

                // ボード状態をコピーして石を置く
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                // 置いた先を探索
                total += this.exploreNLeast(boardLeaf, nowTurn, !turn, nextNum);
                placeNum++;
            }
        }

        // 現在の手番がパス。相手も置けなければゲーム終了(両者パス)
        if (placeNum == 0) {
            if (canPlaceAny(board, !turn)) return this.exploreNLeast(board, nowTurn, !turn, nextNum);
            return 0.0;
        }

        // 符号を反転して平均(相手のおける手数が多いほど評価は低い)
        return -total / placeNum;
    }

    /**
     * n手先(相手の手番基準)の nowTurn の手数を最大化する(再帰).
     * 相手の手番のときだけ depth をインクリメントする.
     * @param board   今の盤面
     * @param nowTurn 評価基準となるプレイヤー(変化しない)
     * @param turn    現在手を打つプレイヤー
     * @param num     現在の探索手数
     * @return        nowTurn にとっての評価値
     */
    public double exploreNMost(long[] board, boolean nowTurn, boolean turn, int num) {
        // 探索し切ったなら今の手数を返す
        if (nowTurn == turn && num >= this.readGoal[nowTurn ? 1 : 0]) {
            int mobility = 0;
            for (int row = 0; row < SIZE; row++)
                for (int col = 0; col < SIZE; col++)
                    if (canPlace(row, col, board, nowTurn)) mobility++;
            return (double) mobility;
        }

        double total = 0.0;
        int placeNum = 0;
        long[] boardLeaf = new long[2];
        // 相手の手番のときだけ depth を進める
        int nextNum = (nowTurn != turn) ? num + 1 : num;

        // 全てのマスをループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 置けないならスキップ
                if (!canPlace(row, col, board, turn)) continue;

                // ボード状態をコピーして石を置く
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                // 置いた先を探索
                total += this.exploreNMost(boardLeaf, nowTurn, !turn, nextNum);
                placeNum++;
            }
        }

        // 現在の手番がパス。相手も置けなければゲーム終了(両者パス)
        if (placeNum == 0) {
            if (canPlaceAny(board, !turn)) return this.exploreNMost(board, nowTurn, !turn, nextNum);
            return 0.0;
        }

        return total / placeNum;
    }
}

ここからが大事なところです.
まず,今まで作ってきたnhand(n手先で自分の意思の数が最大になる位置に置く),nmost(n手先で自分の取れる手数が最大になる位置に置く),nleast(n手先で相手の取れる手数が最小になる位置に置く)の三つをたくさん対戦させます.
そして,それぞれの思考方法が「次はここに石を置くと強い!」と判断した思考過程を言語化して保存しておきます.
盤面を入力,「この位置に置くとこういう理由で強い」を出力としてLLMに追加学習してもらいます.
そうすれば,大戦中の各盤面において,「この盤面ならここに置くと強いな」を適宜判断してくれるAIが誕生するのでは…!? という期待をしています.
先駆者がいるかどうかわからないので何も分からないんですけどね.まあ先駆者がいるかどうかわからないからやるんですけどね.

ちなみに実際のデータの一例はこんな感じ.

{"conversations": [{"role": "user", "content": "  a b c d e f g h\n1 W B B B B W . B \n2 B B B B B W W B \n3 B B W W W B W B \n4 W W W W B W W W \n5 W W W W B B B B \n6 . W W B W B B B \n7 . W W W B W B B \n8 . B B B B B B B \n黒の手番\n置ける位置: g1, a6, a7, a8"},{"role": "assistant", "content": "<think>\n候補手を評価する:\n  a8: 3手先で黒の手が平均1.0手 ← 最大\n  g1: 3手先で黒の手が平均0.8手\n  a6: 3手先で黒の手が平均0.8手\n  a7: 3手先で黒の手が平均0.5手\n→ a8 を選択(3手先で自分の手が最も広がる)\n</think>\na8"}]}

入力は今の盤面と,その盤面の時に石を置ける場所.
出力は「ここに置いたら将来的にこうなって,ここに置いたらこうなって….だからここに置くのが強い!」になります.ちゃんと説明すると,思考過程が<think>タグに囲まれていて,その閉じタグから改行して,実際に置く位置を出力しています.
これがjsonlファイルでたくさん保存されています.

データ集め

以下のソースを動かすだけです.
データの中身は上記で解説していますが,補足すると,対戦して勝った側の思考しか保存しません.
また,たくさん対戦したときにデータに幅を持たせるために,最初の2手は黒と白お互いランダムに置くようにしています.

DataGen.java
DataGen.java
import java.io.*;
import java.util.*;

/**
 * LLM LoRA 学習用データ生成クラス.
 * オセロAI同士を対局させ,各手番の思考テキスト付き JSONL を生成する.
 *
 * 使い方:
 *   javac *.java
 *   java DataGen [--games 100] [--goal 3] [--strategy nHand] [--output ../data/train.jsonl]
 *
 * 出力形式:
 *   {"conversations": [
 *     {"role": "user",      "content": "(盤面テキスト)\n黒の手番"},
 *     {"role": "assistant", "content": "<think>\n(思考テキスト)\n</think>\n(選んだ手)"}
 *   ]}
 */
public class DataGen extends Osero {

    private static final int    GAMES_PER_COMBO             = 1000;
    private static final int    READ_GOAL                   = 3;
    private static final int    OPENING_RANDOM_MOVES_PER_SIDE = 2;
    private static final String OUTPUT_PATH                 = "../data/train.jsonl";

    // ── 思考テキスト付き手の選択 ─────────────────────────────

    /**
     * 指定した思考方法で各候補手を評価し,思考テキストと選んだ手を返す.
     * スコアは z スコア正規化(平均0・標準偏差1)して表示する.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     * @param algo  思考方法
     * @return String[2]: {選んだ座標("d3"形式), 思考テキスト}
     */
    public String[] thinkAndGetMove(long[] board, boolean turn, PlayMethod algo) {
        double maxScore = -Double.MAX_VALUE;
        // 候補手とスコアを記録する
        List<int[]>  candidates = new ArrayList<>(); // {row, col}
        List<Double> scores     = new ArrayList<>();
        List<String> coords     = new ArrayList<>();
        long[] boardLeaf = new long[2];

        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                if (!canPlace(row, col, board, turn)) continue;
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                double score;
                switch (algo) {
                    case N_LEAST: score = this.exploreNLeast(boardLeaf, turn, !turn, 1); break;
                    case N_MOST:  score = this.exploreNMost (boardLeaf, turn, !turn, 1); break;
                    default:      score = this.exploreNHand (boardLeaf, turn, !turn, 1); break;
                }

                candidates.add(new int[]{row, col});
                scores.add(score);
                coords.add(coordToStr(row, col));
                if (score > maxScore) maxScore = score;
            }
        }

        if (candidates.isEmpty()) return null; // 置ける手がない(パス)

        // 思考テキスト生成
        StringBuilder thought = new StringBuilder();
        thought.append("候補手を評価する:\n");
        int depth = this.readGoal[turn ? 1 : 0];

        // スコア降順で並べる
        List<Integer> order = new ArrayList<>();
        for (int i = 0; i < candidates.size(); i++) order.add(i);
        order.sort((a, b) -> Double.compare(scores.get(b), scores.get(a)));

        for (int idx : order) {
            boolean isBest = (scores.get(idx) == maxScore);
            String line;
            switch (algo) {
                case N_HAND:
                    line = String.format("  %s: %d手先で%sが平均%.1f石%s\n",
                        coords.get(idx), depth, turn ? "白" : "黒",
                        scores.get(idx), isBest ? " ← 最大" : "");
                    break;
                case N_LEAST:
                    line = String.format("  %s: %d手先で%sの手が平均%.1f手%s\n",
                        coords.get(idx), depth, turn ? "黒" : "白",
                        -scores.get(idx), isBest ? " ← 最小" : "");
                    break;
                case N_MOST:
                    line = String.format("  %s: %d手先で%sの手が平均%.1f手%s\n",
                        coords.get(idx), depth, turn ? "白" : "黒",
                        scores.get(idx), isBest ? " ← 最大" : "");
                    break;
                default:
                    throw new IllegalArgumentException("未対応の思考方法: " + algo);
            }
            thought.append(line);
        }

        // 最高スコアの手の中からランダムに選ぶ(同スコア対応)
        List<Integer> best = new ArrayList<>();
        for (int i = 0; i < scores.size(); i++)
            if (scores.get(i) == maxScore) best.add(i);

        int chosen = best.get(rand.nextInt(best.size()));
        String chosenCoord = coords.get(chosen);

        if (best.size() > 1) {
            List<String> bestCoords = new ArrayList<>();
            for (int i : best) bestCoords.add(coords.get(i));
            thought.append("同スコアの手: ").append(String.join(", ", bestCoords)).append("\n");
            thought.append("→ ").append(chosenCoord).append(" をランダムに選択");
        } else {
            String reason;
            switch (algo) {
                case N_HAND:  reason = depth + "手先で最も石数が多くなる"; break;
                case N_LEAST: reason = depth + "手先で相手の手を最も制限できる"; break;
                case N_MOST:  reason = depth + "手先で自分の手が最も広がる"; break;
                default:      throw new IllegalArgumentException("未対応の思考方法: " + algo);
            }
            thought.append("→ ").append(chosenCoord).append(" を選択(").append(reason).append(")");
        }

        // 盤面に反映
        int[] pos = candidates.get(chosen);
        place(pos[0], pos[1], board, turn);

        return new String[]{chosenCoord, thought.toString()};
    }


    // ── ゲーム実行とJSONL生成 ─────────────────────────────────

    /**
     * 1ゲームを対局させ,勝者の手番の学習サンプルを writer に書き出す.
     * 冒頭 OPENING_RANDOM_MOVES_PER_SIDE 手と RANDOM の手は学習対象外.
     * 引き分けの場合は両者のサンプルを破棄する.
     * @param black  黒番の思考方法
     * @param white  白番の思考方法
     * @param writer 出力先
     * @return 書き出したサンプル数
     * @throws IOException ファイル書き込みエラー
     */
    public int runGame(PlayMethod black, PlayMethod white, BufferedWriter writer) throws IOException {
        this.initBoard(); // this.board を初期配置にリセット
        PlayMethod[] algos   = {black, white};
        int[]        moveCnt = {0, 0}; // 各プレイヤーの着手数
        boolean      turn    = false;  // 黒から開始
        boolean      prevCouldPlay = true;

        // 終局まで各プレイヤーのサンプルをバッファに溜める
        List<String> blackBuffer = new ArrayList<>();
        List<String> whiteBuffer = new ArrayList<>();

        while (true) {
            if (!canPlaceAny(this.board, turn)) {
                if (!prevCouldPlay) break; // 両者置けない → 終了
                prevCouldPlay = false;
                turn = !turn;
                continue;
            }
            prevCouldPlay = true;

            PlayMethod algo      = algos[turn ? 1 : 0];
            boolean    isOpening = moveCnt[turn ? 1 : 0] < OPENING_RANDOM_MOVES_PER_SIDE;

            if (isOpening || algo == PlayMethod.RANDOM) {
                // 冒頭ランダム手または RANDOM の手: 学習対象外のため打つだけ
                this.random(this.board, turn);
            } else {
                // 思考方法による手: バッファに追加
                String   boardText = boardToText(this.board, turn);
                String[] result    = this.thinkAndGetMove(this.board, turn, algo);
                // thinkAndGetMove の内部で this.board に石が置かれる
                String sample = buildJsonl(boardText, result[1], result[0]);
                if (turn) whiteBuffer.add(sample);
                else      blackBuffer.add(sample);
            }

            moveCnt[turn ? 1 : 0]++;
            turn = !turn;
        }

        // 勝者のバッファのみ書き出す(引き分けは破棄)
        int blackStones = countStones(this.board, false);
        int whiteStones = countStones(this.board, true);
        List<String> winnerBuffer =
            blackStones > whiteStones ? blackBuffer :
            whiteStones > blackStones ? whiteBuffer : null; // 引き分け

        if (winnerBuffer == null) return 0;

        for (String sample : winnerBuffer) {
            writer.write(sample);
            writer.newLine();
        }
        return winnerBuffer.size();
    }

    /** 1サンプル分の JSONL 文字列を組み立てる. */
    private String buildJsonl(String boardText, String thought, String move) {
        // JSON特殊文字のエスケープ
        String escapedBoard   = escapeJson(boardText);
        String escapedContent = escapeJson("<think>\n" + thought + "\n</think>\n" + move);
        return "{\"conversations\": ["
             + "{\"role\": \"user\", \"content\": \"" + escapedBoard + "\"},"
             + "{\"role\": \"assistant\", \"content\": \"" + escapedContent + "\"}"
             + "]}";
    }

    /** JSON文字列内の特殊文字をエスケープする. */
    private String escapeJson(String s) {
        return s.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r")
                .replace("\t", "\\t");
    }

    // ── メインエントリーポイント ──────────────────────────────

    /**
     * データ生成のエントリーポイント.
     * 全アルゴリズムの組み合わせ(RANDOM 同士を除く 15 通り)で対局させ,
     * 思考手のサンプルを OUTPUT_PATH に書き出す.
     * @param args 未使用
     * @throws IOException ファイル書き込みエラー
     */
    public static void main(String[] args) throws IOException {
        DataGen dg = new DataGen();
        dg.setReadGoal(READ_GOAL, READ_GOAL);

        new File(OUTPUT_PATH).getParentFile().mkdirs();

        int totalSamples = 0;
        int totalGames   = 0;
        try (BufferedWriter writer = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(OUTPUT_PATH), "UTF-8"))) {

            PlayMethod[] aiMethods = {
                PlayMethod.RANDOM, PlayMethod.N_HAND, PlayMethod.N_LEAST, PlayMethod.N_MOST
            };
            for (PlayMethod black : aiMethods) {
                for (PlayMethod white : aiMethods) {
                    // RANDOM 同士は双方学習対象外なのでスキップ
                    if (black == PlayMethod.RANDOM && white == PlayMethod.RANDOM) continue;

                    int comboSamples = 0;
                    for (int i = 0; i < GAMES_PER_COMBO; i++) {
                        comboSamples += dg.runGame(black, white, writer);
                    }
                    totalSamples += comboSamples;
                    totalGames   += GAMES_PER_COMBO;
                    System.out.println(black + " (黒) vs " + white + " (白): "
                        + GAMES_PER_COMBO + " ゲーム / " + comboSamples + " サンプル"
                        + " (累計 " + totalGames + " ゲーム / " + totalSamples + " サンプル)");
                }
            }
        }

        System.out.println("\n完了: " + totalGames + " ゲーム / " + totalSamples + " サンプルを " + OUTPUT_PATH + " に保存");
    }
}

完了までに多分一時間以上かかりましたね.

学習実行

以下のソースを動かします.

config.py
config.py
"""学習設定の一元管理"""
from dataclasses import dataclass, field


@dataclass
class ModelConfig:
    # model_name: str = "unsloth/gemma-4-E4B-it"
    model_name: str = "unsloth/Qwen3.5-0.8b"
    max_seq_length: int = 512
    load_in_4bit: bool = True


@dataclass
class LoraConfig:
    r: int = 16
    lora_alpha: int = 32
    lora_dropout: float = 0.05
    target_modules: list = field(default_factory=lambda: [
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ])


@dataclass
class TrainingConfig:
    # data_path: str = "data/train.jsonl"
    data_path: str = "data/train_50k.jsonl"
    # output_dir: str = "outputs/gemma4-e4b-lora"
    output_dir: str = "outputs/qwen3.5-0.8b-lora"
    num_train_epochs: int = 1
    per_device_train_batch_size: int = 2
    gradient_accumulation_steps: int = 4
    learning_rate: float = 2e-4
    warmup_steps: int = 10
    logging_steps: int = 5
    save_steps: int = 50
    fp16: bool = False
    bf16: bool = True
    gradient_checkpointing: bool = True
    seed: int = 42


model_config = ModelConfig()
lora_config = LoraConfig()
training_config = TrainingConfig()
train.py
train.py
"""LoRA ファインチューニング スクリプト"""
from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments
from datasets import load_dataset

from config import model_config, lora_config, training_config


def main():
    """
    LoRA ファインチューニングのメイン処理.
    モデルのロード → LoRA アダプター付加 → データ読み込み → 学習 → 保存 の順で実行する.
    学習されるのは LoRA の A・B 行列のみで,元のモデルの重みは変更されない.
    """
    print("=== モデルロード ===")
    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=model_config.model_name,       # HuggingFace のモデル名.Ollama の gemma4:e4b は GGUF 形式(推論専用)のため学習には使えず,PyTorch 形式の HuggingFace 版が必要
        max_seq_length=model_config.max_seq_length,  # 入力トークン列の最大長(これを超える部分は切り捨て)
        load_in_4bit=model_config.load_in_4bit,   # True にすると 4bit 量子化でロードし VRAM を節約
    )

    print("=== LoRAアダプター付加 ===")
    model = FastLanguageModel.get_peft_model(
        model,
        r=lora_config.r,                          # LoRA のランク.A・B 行列のくびれの幅.大きいほど表現力が増すが VRAM と時間がかかる
        lora_alpha=lora_config.lora_alpha,        # スケーリング係数(LoRA の影響の強さ)= alpha/r.通常 r の 2 倍に設定する
        lora_dropout=lora_config.lora_dropout,    # ドロップアウト率.過学習を防ぐためにランダムに一部の重みを無効化する割合
        target_modules=lora_config.target_modules,  # LoRA を適用する重み行列の種類(W_Q, W_K, W_V 等)
        use_gradient_checkpointing="unsloth",     # unsloth 独自の勾配チェックポイント実装で VRAM を約 70% 削減
    )

    print("=== データ読み込み ===")
    dataset = load_dataset(
        "json",                                   # ファイル形式
        data_files=training_config.data_path,     # 学習データのパス(JSONL 形式)
        split="train",                            # データセットの分割名(train のみ使用)
    )
    print(f"サンプル数: {len(dataset)}")

    # 各会話を Gemma のチャットテンプレート形式(<start_of_turn>user\n...<end_of_turn> 等)に変換する
    def formatting_func(examples):
        texts = []
        for convos in examples["conversations"]:
            text = tokenizer.apply_chat_template(
                convos,
                tokenize=False,              # テキスト文字列として返す(トークン ID にはしない)
                add_generation_prompt=False, # 学習時は生成プロンプトを末尾に追加しない
            )
            texts.append(text)
        return {"text": texts}

    dataset = dataset.map(formatting_func, batched=True)

    print("=== 学習開始 ===")
    trainer = SFTTrainer(
        model=model,
        tokenizer=tokenizer,
        train_dataset=dataset,
        dataset_text_field="text",               # データセット中のテキストが入っているフィールド名
        max_seq_length=model_config.max_seq_length,
        packing=True,                            # 短いサンプルを詰めて max_seq_length に近づけ,パディングを減らして GPU 効率を上げる
        args=TrainingArguments(
            output_dir=training_config.output_dir,                          # チェックポイントの保存先
            num_train_epochs=training_config.num_train_epochs,              # 学習エポック数(データセット全体を何周するか)
            per_device_train_batch_size=training_config.per_device_train_batch_size,  # GPU 1枚あたりのバッチサイズ
            gradient_accumulation_steps=training_config.gradient_accumulation_steps,  # 勾配を何ステップ分ためてから更新するか.一度に計算するサンプル数は少なく済みつつ,実効バッチサイズ = batch_size × このステップ数 相当の安定した学習ができる
            learning_rate=training_config.learning_rate,                    # 学習率.A・B 行列を 1 ステップでどれだけ更新するかの幅
            warmup_steps=training_config.warmup_steps,                      # 学習率を 0 から徐々に上げるステップ数(学習初期の不安定さを防ぐ)
            logging_steps=training_config.logging_steps,                    # 何ステップごとにログ(loss 等)を出力するか
            save_steps=training_config.save_steps,                          # 何ステップごとにチェックポイントを保存するか
            fp16=training_config.fp16,                                      # fp16 と bf16 はどちらか一方のみ True にする
            bf16=training_config.bf16,                                      # bfloat16 精度で学習(Gemma 4 はデフォルト bf16)
            gradient_checkpointing=training_config.gradient_checkpointing,  # 中間結果を再計算することで VRAM を節約する(速度はやや低下)
            gradient_checkpointing_kwargs={"use_reentrant": False},         # PyTorch の勾配チェックポイント実装の設定(False が推奨)
            seed=training_config.seed,                                      # 乱数シード(再現性のため固定)
        ),
    )

    trainer.train()

    print("=== アダプター保存 ===")
    # 学習済みの LoRA アダプター(A・B 行列)とトークナイザーを保存する
    model.save_pretrained(training_config.output_dir)
    tokenizer.save_pretrained(training_config.output_dir)
    print(f"保存先: {training_config.output_dir}")


if __name__ == "__main__":
    main()

ちなみに,最初はgemma4:e4bで3エポック回す予定でした.
しかし僕が使っているGPUのスペックだと,学習完了までに約9日かかることが判明.

やむなくLLMモデルをqwen3.5:0.8bに変更し,エポック数も1に縮小しました.
また,データ数も大量に削って,もともと38万個近くあったものを5万個まで削っています.
それなら一時間もかけてデータ集めする必要なかったじゃん!!!
これでようやく,8時間程度で学習が終わる計算になりました.

ちなみに今回は訓練用のデータのみで,確認用のデータを用意していません.
Claudeが言うにはLLMのファインチューニングではそれが一般的なんだとか.

GGUF形式のファイルを作成

以下のURLから,lamma.cppのビルド済みバイナリを取得します.
https://github.com/ggerganov/llama.cpp/releases

unslothのpythonスクリプトを使ってまずf16のGGUFファイルを作成し,その後上記バイナリファイルを使用して量子化します.
量子化しない場合は1.5GBくらいありますが,量子化することで0.5GBくらいの容量になります.
うん,大きいね.

次に以下の内容のファイルを作成.

FROM ./outputs/qwen3.5-osero-q4_k_m.gguf

あとはコマンド実行してollamaにモデルを登録します.

ollama create qwen3.5-osero -f Modelfile

ちなみに,ollamaを最新版にしないと乗機コマンドが動かないかも?
僕の場合は「アップデートしろ」みたいなエラーが出ました.

動かしてみる

さあ,学習もとになった思考方法と対戦させてみましょう!!!
どのくらい強くなっているかな? それとも弱くなっているかな?

Evaluator.java
Evaluator.java
import java.util.function.BiConsumer;

/**
 * 学習済み LLM を既存 AI アルゴリズムと対戦させ,勝率を評価するクラス.
 *
 * 使い方:
 *   javac *.java
 *   java Evaluator
 */
public class Evaluator extends Osero {

    private static final int GAMES_PER_MATCHUP = 20;
    private static final int READ_GOAL         = 3;

    /** 対戦相手として使用する AI メソッドの一覧 */
    private static final PlayMethod[] OPPONENTS = {
        PlayMethod.N_HAND, PlayMethod.N_LEAST, PlayMethod.N_MOST, PlayMethod.RANDOM
    };

    /**
     * 1ゲームを実行し,勝者を返す.
     * @param black 黒番の思考方法
     * @param white 白番の思考方法
     * @return 1=黒勝ち, -1=白勝ち, 0=引き分け
     */
    public int runGame(PlayMethod black, PlayMethod white) {
        this.initBoard();
        BiConsumer<long[], Boolean> blackPlayer = resolvePlayMethod(black);
        BiConsumer<long[], Boolean> whitePlayer = resolvePlayMethod(white);
        boolean turn          = false;
        boolean prevCouldPlay = true;

        while (true) {
            if (!canPlaceAny(this.board, turn)) {
                if (!prevCouldPlay) break; // 両者置けない → 終了
                prevCouldPlay = false;
                turn = !turn;
                continue;
            }
            prevCouldPlay = true;
            (turn ? whitePlayer : blackPlayer).accept(this.board, turn);
            turn = !turn;
        }

        int blackStones = countStones(this.board, false);
        int whiteStones = countStones(this.board, true);
        if      (blackStones > whiteStones) return  1;
        else if (whiteStones > blackStones) return -1;
        else                                return  0;
    }

    /**
     * 評価のエントリーポイント.
     * LLM を各 AI と GAMES_PER_MATCHUP ゲームずつ対戦させ,勝率を表示する.
     * @param args 未使用
     */
    public static void main(String[] args) {
        Evaluator ev = new Evaluator();
        ev.setReadGoal(READ_GOAL, READ_GOAL);

        System.out.printf("%-10s | %6s %6s %6s | %6s %6s %6s%n",
            "相手", "黒勝", "黒敗", "黒分", "白勝", "白敗", "白分");
        System.out.println("-".repeat(57));

        for (PlayMethod opp : OPPONENTS) {
            int bWin = 0, bLose = 0, bDraw = 0;
            int wWin = 0, wLose = 0, wDraw = 0;

            for (int i = 0; i < GAMES_PER_MATCHUP; i++) {
                // LLM が黒
                int r1 = ev.runGame(PlayMethod.LLM, opp);
                if      (r1 ==  1) bWin++;
                else if (r1 == -1) bLose++;
                else               bDraw++;

                // LLM が白
                int r2 = ev.runGame(opp, PlayMethod.LLM);
                if      (r2 == -1) wWin++;
                else if (r2 ==  1) wLose++;
                else               wDraw++;
            }

            System.out.printf("%-10s | %6d %6d %6d | %6d %6d %6d%n",
                opp, bWin, bLose, bDraw, wWin, wLose, wDraw);
        }
    }
}

しかし実行してみたところ,どうやら無効な手がたくさん出力されている模様.
というわけで,学習に使用しなかったデータを与えてみて,どのような出力が出るのか確かめてみることに.
train.jsonlの先頭1000件を与えてみて,どのような出力が多いか確かめてみます(厳密には,学習に使ったデータも一部含まれます).
良かった.一時間のデータ集めは無駄じゃなかったんだ.

test_inference.py
test_inference.py
"""学習データの先頭 N 件を使って LLM の出力を確認するテストスクリプト."""
import json
import re
import urllib.request
from tqdm import tqdm

DATA_PATH   = "data/train.jsonl"
OLLAMA_URL  = "http://localhost:11434/api/chat"
MODEL_NAME  = "qwen3.5-osero"
N_SAMPLES   = 1000


def call_ollama(user_content: str) -> str:
    """Ollama に問い合わせてレスポンステキストを返す."""
    body = json.dumps({
        "model": MODEL_NAME,
        "stream": False,
        "messages": [{"role": "user", "content": user_content}],
    }).encode("utf-8")
    req = urllib.request.Request(
        OLLAMA_URL,
        data=body,
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    return data["message"]["content"]


def extract_move(text: str) -> str | None:
    """</think> 以降から最初の座標(a-h + 1-8)を抽出する."""
    think_end = text.find("</think>")
    area = text[think_end + 8:].strip() if think_end >= 0 else text.strip()
    m = re.search(r"[a-h][1-8]", area)
    return m.group() if m else None


def main():
    correct = 0
    wrong   = 0
    invalid = 0
    error   = 0

    with open(DATA_PATH, encoding="utf-8") as f:
        lines = [f.readline() for _ in range(N_SAMPLES)]

    for i, line in enumerate(tqdm(lines, desc="推論中")):
        if not line.strip():
            continue

        sample = json.loads(line)
        convs  = sample["conversations"]
        user_content      = convs[0]["content"]
        assistant_content = convs[1]["content"]
        expected_move     = extract_move(assistant_content)

        try:
            response   = call_ollama(user_content)
            llm_move   = extract_move(response)
        except Exception as e:
            print(f"[{i+1:4d}] エラー: {e}")
            error += 1
            continue

        if llm_move is None:
            status = "無効"
            invalid += 1
        elif llm_move == expected_move:
            status = "正解"
            correct += 1
        else:
            status = f"不正解(期待: {expected_move} / 出力: {llm_move}"
            wrong += 1

        # if (i + 1) % 50 == 0 or llm_move != expected_move:
        #     print(f"[{i+1:4d}] {status}")

    total = correct + wrong + invalid + error
    print("\n=== 結果 ===")
    print(f"総数  : {total}")
    print(f"正解  : {correct} ({correct/total*100:.1f}%)")
    print(f"不正解: {wrong}   ({wrong/total*100:.1f}%)")
    print(f"無効手: {invalid} ({invalid/total*100:.1f}%)")
    print(f"エラー: {error}   ({error/total*100:.1f}%)")


if __name__ == "__main__":
    main()

実行結果はこちら.

=== 結果 ===
総数  : 1000
正解  : 0 (0.0%)
不正解: 515   (51.5%)
無効手: 480 (48.0%)
エラー: 5   (0.5%)

う~ん,ゴミ!www
正解は一つもなく,半分近くが無効.
ちなみにエラーと出ているのはタイムアウトです.

なぜこんな結果になったんでしょう.
学習時のログ出力を見る限り,ちゃんと損失は減っていっていたのですが.
1エポックしか学習していないのがいけなかった?
データ数が足りなかった?
そもそもこのアプローチ自体が間違いだった?
原因は色々考えられすぎて分からないですね.

対戦してみよう

しかし,せっかく作ったものと対戦せずに終わってしまうのは非常にもったいない.
自分でも戦ってみることにしましょう.
半分はランダムに打ってきますが,もしかしたら残り半分が優秀かも知れないだろ,という思いで挑んでみます.

ちなみに僕がオセロをするのも実に三年ぶりですね.

Match.java
Match.java
import java.util.function.BiConsumer;

/**
 * 任意の2プレイヤーで1ゲーム対局するクラス.
 *
 * 使い方:
 *   javac *.java
 *   java Match <黒プレイヤー> <白プレイヤー>
 *
 * プレイヤー指定: human / llm / random / nhand / nleast / nmost
 *   例: java Match human llm
 *   例: java Match llm nhand
 */
public class Match extends Osero {

    private static final int READ_GOAL = 3;

    /**
     * 1ゲームを対局する.
     * 毎手番後に盤面を表示し,終局時に結果を出力する.
     * @param black 黒番のプレイヤー
     * @param white 白番のプレイヤー
     */
    public void runGame(PlayMethod black, PlayMethod white) {
        this.initBoard();
        BiConsumer<long[], Boolean> blackPlayer = resolvePlayMethod(black);
        BiConsumer<long[], Boolean> whitePlayer = resolvePlayMethod(white);
        boolean turn          = false; // 黒から開始
        boolean prevCouldPlay = true;

        // 初期盤面を表示
        System.out.println(boardToText(this.board, turn));
        System.out.println();

        while (true) {
            if (!canPlaceAny(this.board, turn)) {
                if (!prevCouldPlay) break; // 両者置けない → 終了
                prevCouldPlay = false;
                System.out.println((turn ? "白" : "黒") + " はパスします\n");
                turn = !turn;
                continue;
            }
            prevCouldPlay = true;

            System.out.println("【" + (turn ? "白" : "黒") + "の番】(" + (turn ? white : black) + ")");
            (turn ? whitePlayer : blackPlayer).accept(this.board, turn);
            turn = !turn;
            // 打った後に盤面を表示(次の手番の視点で valid moves も表示)
            System.out.println(boardToText(this.board, turn));
            System.out.println();
        }

        // 終局
        int blackStones = countStones(this.board, false);
        int whiteStones = countStones(this.board, true);
        System.out.println("=== 終局 ===");
        System.out.println("黒(" + black + "): " + blackStones + " 石");
        System.out.println("白(" + white + "): " + whiteStones + " 石");
        if      (blackStones > whiteStones) System.out.println("→ 黒の勝ち!");
        else if (whiteStones > blackStones) System.out.println("→ 白の勝ち!");
        else                                System.out.println("→ 引き分け");
    }

    /**
     * プレイヤー名の文字列を PlayMethod に変換する.
     * @param s プレイヤー名(human / llm / random / nhand / nleast / nmost)
     * @return 対応する PlayMethod
     */
    private static PlayMethod parsePlayMethod(String s) {
        switch (s.toLowerCase()) {
            case "human":  return PlayMethod.HUMAN;
            case "llm":    return PlayMethod.LLM;
            case "random": return PlayMethod.RANDOM;
            case "nhand":  return PlayMethod.N_HAND;
            case "nleast": return PlayMethod.N_LEAST;
            case "nmost":  return PlayMethod.N_MOST;
            default: throw new IllegalArgumentException(
                "不明なプレイヤー指定: " + s
                + "\n有効な値: human / llm / random / nhand / nleast / nmost");
        }
    }

    /**
     * 対局のエントリーポイント.
     * @param args args[0]=黒プレイヤー, args[1]=白プレイヤー
     */
    public static void main(String[] args) {
        if (args.length != 2) {
            System.err.println("使い方: java Match <黒プレイヤー> <白プレイヤー>");
            System.err.println("  例: java Match human llm");
            System.err.println("  例: java Match llm nhand");
            System.exit(1);
        }

        PlayMethod black, white;
        try {
            black = parsePlayMethod(args[0]);
            white = parsePlayMethod(args[1]);
        } catch (IllegalArgumentException e) {
            System.err.println(e.getMessage());
            System.exit(1);
            return;
        }

        Match match = new Match();
        match.setReadGoal(READ_GOAL, READ_GOAL);
        System.out.println("黒: " + black + " / 白: " + white + "\n");
        match.runGame(black, white);
    }
}

まず,僕が黒,LLMが白でやってみます.
結果がこちら(終盤でタイムアウトしてしまったため途中で終了).

  a b c d e f g h
1 B . B B B B B B 
2 B B B B B B B B 
3 B B B B B B B B 
4 B B B B B B B B 
5 B B B B B B W B 
6 B B B B B W B B 
7 B B B B B B B B 
8 . . B W W B B B

ボコボコですやん.
白の石の数,四個しかないですやん.

何かの間違いかも知れませんので,もう一度やってみましょう.今度は僕が白です.

  a b c d e f g h
1 W W W W W W W W 
2 B W W W B B B B 
3 W B B B W W B W 
4 W B B W W W B W 
5 W B W B W W B W 
6 W B W W B W W W 
7 W B W W W B B W 
8 W B B B B B B W

=== 終局 ===
黒(LLM): 26 石
白(HUMAN): 38 石
→ 白の勝ち!

思ったより接戦になりましたが,角を全部抑えて僕の勝ち.

というかどちらの試合もそうですが,LLMは終始ランダムにしか打ってきませんでした.
せっかくLoRAしたのに全く学習していません.

次回は

次回はどうしましょうか.

  • 精度が低かった理由を探ってみる
  • 他の方法にチャレンジしてみる
  • 前回勾配爆発してしまった方法に再チャレンジしてみる

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?