需要准备的资料

SGF是一种以文本形式保存棋盘大小、黑白落子和对局结果的格式。首先,只收集已经确认过出处和再利用条件的棋谱。比文件数量更重要的,是筛掉解析失败的棋谱、不是19×19的棋盘以及落子坐标损坏的对局。

  1. 检查棋盘大小是否为19×19。
  2. 从头重放黑白双方的着法顺序。
  3. 把每一手落子之前的盘面保存为输入。
  4. 把实际下出的下一个坐标保存为正确答案。
  5. 同时保留对局结果和手数编号。

第1步:在空棋盘上重放着法

读取SGF坐标时,只把原始文件中的字母坐标一次性转换为内部的0~18行、列。之后统一让规则引擎只使用数字坐标。把一手保存为数据后,再把那颗棋子放到实际棋盘上,移除被提的棋子,然后进入下一手。

如果在这个过程中开启合法着法检查,就能很快发现原始棋谱和解析器的错误。如果正常棋谱被判定为自杀,多半是坐标方向、黑白轮次或提子顺序中的某一项出了错。

第2步:把一局拆分成多个训练样本

一份100手的棋谱不是一个样本,而是最多100个输入与正确答案的配对。第一个样本是空棋盘和黑棋的第一手,第二个样本是放了一颗黑子的盘面和白棋的应手。输入中必须标明当前轮到谁,这样在相同的盘面形状下才能区分该哪种颜色落子。

第3步:确认重复和偏差

如果同一个盘面在多个文件中反复出现,它在训练中的比重可能会变得过大。把棋盘布局、当前轮次和劫的信息组合起来生成哈希,以减少重复。只保留胜方着法的做法虽然简单,但会失去从败方着法中能学到的所有防守和失误模式。在MariaGO中,我们优先使用胜方棋谱,但会把有叫吃和提子的局面单独做标记,以便进行评估。

需要确认的结果

随机抽取20个样本,亲手在输入盘面上标出正确着法。必须用肉眼确认左右或上下没有颠倒,以及在两位数的列号处对齐没有错位。仅凭数据量很多的统计,是发现不了坐标转换错误的。

这一步的实际代码

这个函数把SGF的字母坐标一次性转换为0~18的行、列。在19×19棋盘上,tt被当作停一手处理。

vlm_server/sgf_parser.pypython · 16 行
def _decode_point(s: str, size: int) -> Optional[Tuple[int, int]]:
    if not s:
        return None
    s = s.strip().lower()
    if not s:
        return None
    # SGF: 'tt' 는 19x19 에서 패스 관용 표기
    if size == 19 and s == "tt":
        return None
    if len(s) < 2:
        return None
    col = ord(s[0]) - ord("a")
    row = ord(s[1]) - ord("a")
    if not (0 <= col < size and 0 <= row < size):
        return None
    return (row, col)

这个函数把一局按每一手拆分成“上一盘面、轮到谁、正确坐标”的样本。winning_side_only对应优先使用胜方着法,tactical_bias对应给叫吃、提子局面做标记。

vlm_server/dataset_baduk.pypython · 80 行
def iter_training_samples(
    game: SgfGame,
    max_per_game: int,
    rng: random.Random,
    winning_side_only: bool = False,
    winner: Optional[int] = None,
    tactical_bias: bool = False,
    tactics_only: bool = False,
) -> Iterator[Tuple[bytes, int, Optional[Tuple[int, int]], bool]]:
    """게임 1개에서 (board_before_move, ai_side, target_move, is_tactical) 4-tuple yield."""
    if game.size != BOARD_SIZE:
        return
    sim = _GoSim(BOARD_SIZE)
    for r, c in game.setup_black:
        sim.set(r, c, 1)
    for r, c in game.setup_white:
        sim.set(r, c, 2)

    # tactics_only 든 tactical_bias 든 어느 쪽이라도 분류기를 켜야 한다.
    need_tactical_flag = tactical_bias or tactics_only

    states: List[Tuple[bytes, int, Optional[Tuple[int, int]], bool]] = []
    # (snap_before, side, point, is_tactical)
    for color, point in game.moves:
        side = 1 if color == "B" else 2
        if winning_side_only and winner is not None and side != winner:
            if point is not None:
                sim.play(point[0], point[1], side)
            continue
        snap = sim.packed()
        is_tac = _is_tactical_move(sim, side, point) if need_tactical_flag else False
        states.append((snap, side, point, is_tac))
        if point is not None:
            sim.play(point[0], point[1], side)

    if not states:
        return

    if tactics_only:
        # 전술 순간만 yield (max_per_game 무시).
        for snap, side, point, is_tac in states:
            if is_tac:
                yield (snap, side, point, True)
        return

    if max_per_game <= 0 or len(states) <= max_per_game:
        chosen_indices = list(range(len(states)))
    else:
        if tactical_bias:
            # 절반은 전술 순간 우선, 나머지는 균등.
            tactical_idx = [i for i, st in enumerate(states) if st[3]]
            half = max_per_game // 2
            if len(tactical_idx) > half:
                rng.shuffle(tactical_idx)
                tactical_pick = sorted(tactical_idx[:half])
            else:
                tactical_pick = sorted(tactical_idx)
            need = max_per_game - len(tactical_pick)
            # 나머지 균등 샘플 (tactical_pick 와 중복 피함)
            taken = set(tactical_pick)
            uniform: List[int] = []
            if need > 0:
                step = max(1.0, len(states) / need)
                k = 0
                while len(uniform) < need and k < len(states):
                    idx = int(k * step)
                    if idx >= len(states):
                        break
                    if idx not in taken:
                        uniform.append(idx)
                        taken.add(idx)
                    k += 1
            chosen_indices = sorted(set(tactical_pick + uniform))
        else:
            step = len(states) / max_per_game
            chosen_indices = sorted(set(int(i * step) for i in range(max_per_game)))

    for i in chosen_indices:
        snap, side, point, is_tac = states[i]
        yield (snap, side, point, is_tac)

使用的模型:Qwen3-VL-8B-Instruct + LoRA适配器(lora_mix_qwen_r5)。完整源代码和模型说明可以在“8. 完整源代码与使用的模型”一文中查看。