这篇咱们从零搭一个东西:拿果蝇连接组里真实的接线,驱动一个在终端下的井字棋 agent,外面兜一层 minimax。跑通大概四十分钟。
跑完你手里有两样:一个带开关的玩具,和一份"这项目里哪些是真的、哪些是我自己编的"分栏清单。
第二样才是重点。第一样随便玩玩就过去了,第二样是你以后每接一个生物数据集都要用的习惯。
开搞之前先确认这几件事都齐了:
- 有 neuPrint 的免费 token(注册就给,不用申请经费)
- Python 3.10 以上,用 venv 隔一下
- pip 能装 neuprint-python、networkx、pandas
- 接受一件事:这玩意下棋基本不靠果蝇
最后一条不是玩笑,等会儿你就明白了。缺哪样先补哪样。
Step 1:先想清楚要切哪一块,别急着写代码
果蝇的神经系统已经被整只画进公开数据库了,每个神经元、每条连接都在里面。你不可能把整张图喂给井字棋——不是算不动,是没意义,那里面百分之九十九的神经元跟落子这件事没有任何关系。
所以第一步不是拉数据,是选切片。前阵子有个开发者就是这么干的:他挑了两拨神经元,一群往大脑外发指令的输出神经元,加上给这些神经元喂信号的中央脑神经元。就这两拨,其余统统不看。
为什么卡在这么一条缝上?因为这是整个系统里唯一一处"信号流进去、动作流出来"的接口层。再往里切你够不着输入,再往外切你只剩输出没输入。搭这类玩具,先找到这个接口层,后面的事才谈得上。
这一步没代码,验证方式也很朴素:你能用一句话说清"我的输入从哪些神经元进、输出从哪些神经元读",就算过了。说不清,回去再切。
Step 2:环境接上数据库
装依赖:
python -m venv .venv && source .venv/bin/activate
pip install neuprint-python networkx pandas
token 丢进环境变量,别写死在代码里:
export NEUPRINT_TOKEN="你申请到的那个"
连接是这样:
import os
from neuprint import Client
client = Client(
"neuprint.janelia.org",
dataset="...", # 填你 token 能访问的那个数据集
token=os.environ["NEUPRINT_TOKEN"],
)
⚠️ 这一步很多人会卡在 dataset 这个字段上。它不是一个通用常量,不同数据集名字不一样。填错了会直接返回一个空的查询结果,看着像"这数据不存在",其实是名字没对上。不确定就去你 token 的页面上把数据集名原样复制过来。
顺便说一句版本的事,这一步是我写到后面才想起来必须提前交代的。这类工具更新很快,建议你装完就把版本钉死写进 requirements.txt,顺手在注释里标上今天日期。半年后再回来看这篇,字段对不上是常态,别第一时间怀疑自己。这习惯是我被坑过之后才养成的,现在每个教程我都写。
查询能返回一行非空结果,这步算通了。
Step 3:把接线变成一张有符号的加权图
这一步是整个项目里唯一"全真"的部分,得做扎实。
从数据库里取出来的每条连接,你手里有三个东西:谁连谁、连接有多强、这条连接是兴奋还是抑制。强度直接用突触计数,这是真实测出来的数字,不用你换算。兴奋还是抑制,来自数据库对神经递质的预测——规则是:乙酰胆碱当兴奋,GABA 和谷氨酸当抑制,其他的一律中性。
import networkx as nx
G = nx.DiGraph()
for row in connections:
sign = +1 if row.nt == "acetylcholine" else -1 if row.nt in ("gaba", "glutamate") else 0
G.add_edge(row.pre, row.post, weight=row.synapse_count, sign=sign)
⚠️ 这里必须停一下:神经递质是预测,不是确定事实。数据库是根据形态和已知标记推断的,推断就有错。你在代码里拿它当符号用没问题,但别在有结论性质的输出里把它说成"果蝇的兴奋性连接",这两个说法不是一个东西。
现在你手里有一张带权带符号的有向图,节点是真神经元,边是真突触,权重是真计数。这张图就是后面要用的"接线"。到这一步为止,没有任何一点是我编的。
Step 4:做输入输出映射——从这里开始全是我编的
接下来 9 个格子对应 9 组神经元,玩家落子就把对应那组往一个方向推,对手落子就往相反方向推。信号在这张图里顺着连接流几轮,9 个输出组里活动最强的那个空格子,就是这次"果蝇想下"的位置。
INPUT_GROUPS = {cell: neurons for cell, neurons in zip(CELLS, input_slices)}
OUTPUT_GROUPS = {cell: neurons for cell, neurons in zip(CELLS, output_slices)}
def push(state, move, player):
for n in INPUT_GROUPS[move]:
activation[n] += +DRIVE if player == ME else -DRIVE
这四行字里,棋盘是我编的,分组是我编的,推动方向我编的,读取规则也是我编的。果蝇脑子里没有井字棋,没有"格子"这个概念,更没有一个神经元负责左上角。这段代码之所以能跑,纯是因为我强行给它接了一副外骨骼。
这不是这个项目的缺陷,是它的全部。真正容易出事的是后面的态度:很多"生物启发"的项目走到这一步就不吭声了,把外骨骼藏在"基于真实神经数据"这句话后面。这个作者没有,他专门列了一栏,写清楚哪些来自数据库、哪些是自己设计的,接线固定、果蝇在这里不学习。我觉得那一栏比前面那张图值钱。
Step 5:把 minimax 兜在外面
现在能跑了,但先别急着让它下。你直接拿刚才那套东西下棋,会看到一件很尴尬的事:它走进双叉局面,然后忘了挡。
这不是 bug,是它的真实水平。那套电路没有前瞻能力,只能对当下的信号分布做反应,推演不了"我下这里你会怎么走"。所以外面得兜一层 minimax。
def choose_move(board, unbeatable=True):
best = minimax_best_moves(board) # 列出等价的最佳着法
if not unbeatable:
return circuit_pick(board) # 原始电路单独跑
return circuit_pick(board, among=best) # 果蝇只在等价着法里破平局
关键在最后一行。果蝇不是在做决策,它是在 minimax 给出的几个等价选项之间挑一个。有些局面下等价着法只有一步,这时候它连挑的资格都没有,纯挂件。
我特意留了 unbeatable=False 这个开关,把 minimax 关掉,让原始电路单独跑。它会输。留着这个开关,是为了让你随时能把"哪部分是果蝇做的"当场验证——拨过去,看它输。
Step 6:跑一遍,确认自己没骗自己
从终端起一局,走几步,然后做两件事。
第一,同一个棋盘连跑两次,落子应该完全一样。这个是确定的,没有随机数掺进去。两次结果不同,说明你在某处引入了随机或者遍历顺序不稳定,先修这个。
第二,盯着它的落子看一会儿——会不会老往某几个格子上贴。这种"方格偏置"很可能存在,因为 9 个格子的顺序是人为排的,不是果蝇排的。作者自己也承认没测过这一条,说这是后面可以测的方向。
到这里,这个 agent 就搭完了。照这个清单自查:
- 查询返回非空,token 和环境变量都对得上
- 图里的边有权重也有符号,抽一条边能对上数据库里的原始计数
- 关掉开关,原始电路会输;打开开关,minimax 不败
- 同一棋盘两次落子完全一致
- 手上有一份写清楚"真实/自编"两栏的说明
都打勾了,这篇交付。
接下来你可以试着让它顺手做点别的:把方格顺序换成几套不同的排法,跑一百局统计落子分布,看偏置到底有多大;或者把现在这套纯信号流的模拟换成带时序的脉冲模型——后者是真能把"这不像活体神经元"那条短板补上一截,但那是另一套东西,涉及的时间尺度、参数标定、验证方法都不在这篇范围内,改天单开一篇细讲。先把手上这个跑通。
两件收尾的事。这项目代码是 MIT,但连接组数据是 CC-BY,你抄回去给别人看的时候把数据创建者的署名带上,这不是可选项。另外,哪天真做出点什么,别只发一张"果蝇下棋赢了"的截图,把那张真实/自编的分栏表一并贴出来,那才是别人能接着往前走的东西。
