ローカルLLMの最新の手頃なモデル
最近ローカルLLMでQwen3.8というモデルが話題になっている.
以前にはKimiなどのモデルが話題になっていたが, あちらのモデルはマシンスペックに非常に高いものを要求するため, 一般的な個人PCで使えるかというと, なかなか難しい現実があった.
他方で今回のQwen3.8のモデルは, 当然ある程度のマシンスペックは求められるものの, 少々いいマシンを使っている個人ユーザーなら現実的に動くレベルの要求であり, その点から使い勝手の良いモデルでありつつ, 非常に高性能なモデルであると評判になっている.
そこで私もこのモデルを自分のマシンで動かしてみた.
テスト対象は, 以前たびたび使っていたWeasel ProgramをColab(Google Colaboratory)で動くように書いてもらったものである.
プロンプトは下記であり, 出力は以下の通りである.
Google Colaboratoryで動くPythonコードでWeasel Program を可能な限りリッチな表現を用いたコードで書いて.
その過程をアニメーションとして出力できるようにして.
# @title Weasel Program (Colab) - rich animation + GIF output
import string
from dataclasses import dataclass
from typing import Optional, List, Tuple
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.patches import Rectangle
from IPython.display import Image, display
# Pillow check for GIF export (usually already installed in Colab)
try:
import PIL # noqa: F401
except ImportError as exc:
try:
from IPython import get_ipython
if get_ipython() is None:
raise exc
get_ipython().system("pip install -q pillow")
except Exception as e:
raise ImportError("Pillow is required for GIF export.") from e
@dataclass(frozen=True)
class WeaselConfig:
target: str = "METHODOFSCIENTIFICINVESTIGATION"
alphabet: str = string.ascii_uppercase
offspring_size: int = 100
max_generations: int = 250
base_mutation_rate: float = 0.10
min_mutation_rate: float = 0.04
seed: Optional[int] = 7
class WeaselProgram:
"""Classic Weasel Program with vectorized mutation and adaptive selection."""
def __init__(self, cfg: WeaselConfig) -> None:
self.cfg = cfg
if len(cfg.target) == 0:
raise ValueError("target must not be empty.")
if len(cfg.alphabet) == 0:
raise ValueError("alphabet must not be empty.")
if len(set(cfg.alphabet)) != len(cfg.alphabet):
raise ValueError("alphabet must not contain duplicate characters.")
if cfg.offspring_size < 1:
raise ValueError("offspring_size must be >= 1.")
if cfg.max_generations < 0:
raise ValueError("max_generations must be >= 0.")
if not (0.0 <= cfg.min_mutation_rate <= 1.0):
raise ValueError("min_mutation_rate must be between 0 and 1.")
if not (0.0 <= cfg.base_mutation_rate <= 1.0):
raise ValueError("base_mutation_rate must be between 0 and 1.")
if any(ch not in cfg.alphabet for ch in cfg.target):
raise ValueError("Target contains characters outside the alphabet.")
self.alphabet = np.array(list(cfg.alphabet), dtype="<U1")
self.A = len(self.alphabet)
self.L = len(cfg.target)
char_to_code = {ch: i for i, ch in enumerate(cfg.alphabet)}
self.target_codes = np.array([char_to_code[ch] for ch in cfg.target], dtype=np.int64)
self.rng = np.random.default_rng(cfg.seed)
self.current_codes = self.rng.integers(0, self.A, size=self.L)
self.fitness = int(np.sum(self.current_codes == self.target_codes))
def _mutation_rate(self, fitness: int) -> float:
if fitness >= self.L:
return 0.0
rate = self.cfg.base_mutation_rate * (1.0 - fitness / self.L)
return float(max(self.cfg.min_mutation_rate, rate))
def step(self) -> None:
cfg = self.cfg
rate = self._mutation_rate(self.fitness)
offspring = np.repeat(self.current_codes[None, :], cfg.offspring_size, axis=0)
offspring[0] = self.current_codes # elitism: keep the current best unmutated
mask = self.rng.random(offspring.shape) < rate
mask[0, :] = False
if mask.any():
offspring[mask] = self.rng.integers(0, self.A, size=int(mask.sum()))
fitnesses = np.sum(offspring == self.target_codes, axis=1)
best_idx = int(np.argmax(fitnesses))
self.current_codes = offspring[best_idx].copy()
self.fitness = int(fitnesses[best_idx])
def run(self) -> List[Tuple[int, int, float, np.ndarray]]:
history: List[Tuple[int, int, float, np.ndarray]] = []
for gen in range(self.cfg.max_generations + 1):
rate = self._mutation_rate(self.fitness)
history.append((gen, self.fitness, rate, self.current_codes.copy()))
if self.fitness == self.L:
break
self.step()
return history
def make_animation(
history: List[Tuple[int, int, float, np.ndarray]],
cfg: WeaselConfig,
):
L = len(cfg.target)
target_chars = list(cfg.target)
alphabet = np.array(list(cfg.alphabet), dtype="<U1")
char_to_code = {ch: i for i, ch in enumerate(cfg.alphabet)}
target_codes = np.array([char_to_code[ch] for ch in target_chars], dtype=np.int64)
gens_all = np.array([h[0] for h in history], dtype=int)
fits_all = np.array([h[1] for h in history], dtype=int)
mrs_all = np.array([h[2] for h in history], dtype=float)
max_gen = int(gens_all.max()) if len(gens_all) else 0
fig_width = min(20.0, max(10.0, L * 0.45))
fig, (ax_text, ax_fit) = plt.subplots(
2,
1,
figsize=(fig_width, 6.4),
gridspec_kw={"height_ratios": [1.35, 1.0]},
)
fig.suptitle("Weasel Program: Random Mutation + Selection", fontsize=15, weight="bold")
ax_text.axis("off")
ax_text.set_xlim(-4.0, L + 1.0)
ax_text.set_ylim(-0.35, 2.75)
ax_text.set_aspect("auto")
# Static target cells
for i in range(L):
ax_text.add_patch(Rectangle((i, 1.85), 1.0, 0.7, facecolor="#eceff1", edgecolor="none"))
# Dynamic current cells
current_patches = []
for i in range(L):
patch = Rectangle((i, 0.85), 1.0, 0.8, facecolor="#f5f5f5", edgecolor="none")
ax_text.add_patch(patch)
current_patches.append(patch)
ax_text.text(-1.0, 2.2, "Target:", ha="right", va="center", fontsize=10, color="#455a64")
ax_text.text(-1.0, 1.25, "Current:", ha="right", va="center", fontsize=10, color="#455a64")
current_texts = []
for i, ch in enumerate(target_chars):
ax_text.text(
i + 0.5,
2.2,
ch,
ha="center",
va="center",
family="monospace",
fontsize=12,
color="#37474f",
)
current_texts.append(
ax_text.text(
i + 0.5,
1.25,
" ",
ha="center",
va="center",
family="monospace",
fontsize=16,
weight="bold",
)
)
ax_fit.set_title("Fitness and mutation rate", fontsize=11)
ax_fit.set_xlabel("Generation")
ax_fit.set_ylabel("Correct characters", color="tab:green")
ax_fit.set_xlim(0, max(max_gen, 1))
ax_fit.set_ylim(0, L + 2)
ax_fit.grid(alpha=0.3)
ax_fit.axhline(L, color="gray", linestyle="--", linewidth=1.0)
line_fit, = ax_fit.plot([], [], color="tab:green", lw=2.5, label="Fitness")
ax_mr = ax_fit.twinx()
line_mr, = ax_mr.plot([], [], color="tab:orange", lw=1.8, alpha=0.9, label="Mutation rate")
ax_mr.set_ylabel("Mutation rate", color="tab:orange")
max_rate = max(cfg.base_mutation_rate, cfg.min_mutation_rate) * 1.35
ax_mr.set_ylim(0, max(max_rate, 0.1))
ax_fit.legend(handles=[line_fit, line_mr], labels=["Fitness", "Mutation rate"], loc="upper left")
def update(frame: int):
gen = int(gens_all[frame])
fit = int(fits_all[frame])
codes = history[frame][3]
chars = alphabet[codes]
correct = codes == target_codes
for i, (txt, patch) in enumerate(zip(current_texts, current_patches)):
txt.set_text(chars[i])
if correct[i]:
txt.set_color("#1b5e20")
patch.set_facecolor("#c8e6c9")
else:
txt.set_color("#b71c1c")
patch.set_facecolor("#ffcdd2")
line_fit.set_data(gens_all[:frame + 1], fits_all[:frame + 1])
line_mr.set_data(gens_all[:frame + 1], mrs_all[:frame + 1])
fig.suptitle(
f"Weasel Program | Generation {gen} | Fitness {fit}/{L} ({100.0 * fit / L:.1f}%)",
fontsize=15,
weight="bold",
)
return current_texts + current_patches + [line_fit, line_mr]
fig.tight_layout(rect=[0.0, 0.02, 1.0, 0.95])
ani = FuncAnimation(fig, update, frames=len(history), interval=80, blit=False)
return ani, fig
# ---- Run ----
cfg = WeaselConfig()
program = WeaselProgram(cfg)
history = program.run()
final_gen, final_fit, _, final_codes = history[-1]
print(f"Final generation: {final_gen}")
print(f"Fitness: {final_fit}/{len(cfg.target)}")
print("Final string:", "".join(program.alphabet[final_codes]))
ani, fig = make_animation(history, cfg)
# Output the process as an animated GIF in Colab.
ani.save("weasel_animation.gif", writer="pillow", fps=12)
plt.close(fig)
display(Image(filename="weasel_animation.gif"))
1世代, 2世代前のモデルの頃を思い出すような出力だったが, 逆に裏を返すと, 1世代, 2世代前のモデルが個人PCでもはや無限に動かせてしまうような時代になっている. この進化の速度は本当に驚異的である.
なおかつローカルの良いところは, トークンの消費量を気にせず, 電気代だけで延々と処理を回すことができる点にある.
現在私はCodexを主に使っているが, このモデルでかなり本格的な開発をしている人も見かけたので, 簡単なプログラムやちょっとしたツールであれば, 例えばローカル側で賄うように切り替えられないか, いろいろと試してみたいと考えている.
参考文献
- Dawkins, R. (1996). The blind watchmaker: Why the evidence of evolution reveals a universe without design. WW Norton & Company.