TL;DR
試す前に「何を試すか・何なら合格か」をファイルに書き、そのファイルの sha256 を記録してから結果を見る。
あとで線を動かしたくなったら、上書きではなく「理由つきの追記」だけを許す。
これだけで、A/B テストでも機械学習の実験でもバックテストでも、「結果を見てから都合のいい線を引く」を仕組みで止められます。
最後にコピーして使える約60行とテストを置きます。
事前宣言のテンプレートと判定エンジンは GitHub に置いてあります(MIT)。
なぜ要るのか
半年で約200回の検定をしました(題材は売買ルールですが、中身は普通の仮説検定です)。そこで分かったのは、
- 何十通りも試して一番良いものだけ見せると、それだけで「効いている」ように見える
- 結果を見た後だと、「この閾値は少し厳しすぎた」「この期間は特殊だった」と、線を動かす理由がいくらでも思いつく
- しかも、動かした本人には悪気がない
ということでした。100回じゃんけんして勝った1回だけ報告するのと同じです。
人の意志で止めるのは難しいので、ファイルの指紋(sha256)で止めることにしました。
何を書くか(plan.md)
最低限この4つです。
# 事前宣言
- 仮説: 〇〇のとき、△△は上がりやすい
- 試す案: 4つ(パラメータ A = 1/2 × B = x/y)。これ以上は増やさない
- 期間: 発見 2020〜2023(案を選ぶ)/ 追試 2024〜(選んだ1案だけ)
- 合格: 追試で 平均 > 0 かつ 並べ替え検定の p < 0.05 かつ 件数 ≥ 100
ポイントは 「案の数」と「合格の線」を先に書くことです。案の数を書いておかないと、あとで「実はもう2つ試した」が紛れ込みます。
鍵をかける(コード)
"""prereg_lock.py -- 結果を見る前に「何を試すか・何なら合格か」をファイルに書き、sha256 で鍵をかける。
使い方:
python prereg_lock.py lock plan.md analysis.py # 鍵をかける(lock.json を作る)
python prereg_lock.py check # 鍵をかけた後にファイルが変わっていないか
python prereg_lock.py amend "理由" analysis.py # 変えるなら、理由と新しい指紋を追記(上書きしない)
"""
import datetime as dt
import hashlib
import json
import pathlib
import sys
LOCK = pathlib.Path("lock.json")
def sha256(path):
return hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest()
def now():
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
def lock(paths):
if LOCK.exists():
raise SystemExit("lock.json がもうある。変えるなら amend を使う(上書きしない)")
data = {"locked_at": now(), "files": {p: sha256(p) for p in paths}, "amendments": []}
LOCK.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
return data
def current():
"""最初の鍵に、追記の指紋を順に上書きした「今の正しい指紋」"""
data = json.loads(LOCK.read_text(encoding="utf-8"))
files = dict(data["files"])
for a in data["amendments"]:
files.update(a["files"])
return data, files
def check():
_, files = current()
changed = [p for p, h in files.items() if not pathlib.Path(p).exists() or sha256(p) != h]
return changed
def amend(reason, paths):
data, _ = current()
data["amendments"].append({"at": now(), "reason": reason, "files": {p: sha256(p) for p in paths}})
LOCK.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
return data
if __name__ == "__main__":
cmd = sys.argv[1]
if cmd == "lock":
print(json.dumps(lock(sys.argv[2:]), ensure_ascii=False, indent=1))
elif cmd == "check":
bad = check()
print("OK: 鍵をかけた時のまま" if not bad else "変わっている: " + ", ".join(bad))
sys.exit(1 if bad else 0)
elif cmd == "amend":
print(json.dumps(amend(sys.argv[2], sys.argv[3:]), ensure_ascii=False, indent=1))
使い方:
python prereg_lock.py lock plan.md analysis.py # 結果を見る前に
python analysis.py # ここで初めて結果を見る
python prereg_lock.py check # 見た後に、何も変わっていないことを確かめる
変えたくなったら「追記」だけ
実際には、結果を見る前にバグが見つかることがあります。そのときは上書きせず、理由と新しい指紋を追記します。
python prereg_lock.py amend "型変換のバグ修正(結果はまだ見ていない)" analysis.py
lock.json には最初の指紋と、すべての追記が時刻つきで残ります。「いつ・なぜ・結果を見る前か後か」が、あとから誰でも確かめられるのが大事です。
私の場合、実行が時間切れで結果が出ないまま高速化のためにコードを書き直したときも、この追記で「結果を見る前の変更」だと記録しました。
テスト
import os, pathlib, tempfile, unittest
import prereg_lock as P
class T(unittest.TestCase):
def setUp(self):
self.d = tempfile.mkdtemp(); self.cwd = os.getcwd(); os.chdir(self.d)
pathlib.Path("plan.md").write_text("合格: 平均 > 0 かつ p < 0.05\n", encoding="utf-8")
pathlib.Path("analysis.py").write_text("THRESHOLD = 0.05\n", encoding="utf-8")
def tearDown(self):
os.chdir(self.cwd)
def test_lock_then_check_ok(self):
P.lock(["plan.md", "analysis.py"])
self.assertEqual(P.check(), [])
def test_silent_edit_is_caught(self):
P.lock(["plan.md", "analysis.py"])
pathlib.Path("analysis.py").write_text("THRESHOLD = 0.10\n", encoding="utf-8") # 結果を見て線を緩めた
self.assertEqual(P.check(), ["analysis.py"])
def test_amend_records_reason_and_keeps_history(self):
P.lock(["plan.md", "analysis.py"])
pathlib.Path("analysis.py").write_text("THRESHOLD = 0.05 # バグ修正: 型の変換\n", encoding="utf-8")
data = P.amend("バグ修正(結果は未確認)", ["analysis.py"])
self.assertEqual(P.check(), [])
self.assertEqual(len(data["amendments"]), 1)
self.assertIn("files", data) # 最初の指紋は残る
def test_second_lock_refused(self):
P.lock(["plan.md"])
with self.assertRaises(SystemExit):
P.lock(["plan.md"])
if __name__ == "__main__":
unittest.main()
4 passed
「結果を見て閾値を 0.05 → 0.10 に緩めた」ケースが check で必ず捕まることを、テストで固定しています。
運用して分かったこと
- 一番効くのは「案の数」を先に書くこと。 指紋より先に、ここで大半の後付けが止まる
-
鍵をかけたら、
lock.jsonをすぐ git に push する。 手元のファイルは自分で書き換えられるので、時刻を外に残しておくと「結果を見る前」の証拠になる - 不合格が続いても線は動かさない。 私は1日に10本検定して10本とも不合格でした。でも、それは「線が正しく働いている」ということでもあります
チェックリスト(コピー用)
- 仮説・案の数・期間・合格の線を plan.md に書いた
-
結果を見る前に
lockした -
見た後に
checkが OK -
変更は
amendで理由つき。結果を見る前か後かを書いた - 不合格でも、線を動かしていない