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?

結果を見てから線を動かさない — 事前宣言を sha256 で「鍵」にする約60行(Python)

0
Last updated at Posted at 2026-09-27

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 で必ず捕まることを、テストで固定しています。


運用して分かったこと

  1. 一番効くのは「案の数」を先に書くこと。 指紋より先に、ここで大半の後付けが止まる
  2. 鍵をかけたら、lock.json をすぐ git に push する。 手元のファイルは自分で書き換えられるので、時刻を外に残しておくと「結果を見る前」の証拠になる
  3. 不合格が続いても線は動かさない。 私は1日に10本検定して10本とも不合格でした。でも、それは「線が正しく働いている」ということでもあります

チェックリスト(コピー用)

  • 仮説・案の数・期間・合格の線を plan.md に書いた
  • 結果を見る前に lock した
  • 見た後に check が OK
  • 変更は amend で理由つき。結果を見る前か後かを書いた
  • 不合格でも、線を動かしていない
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?