Skip to content

经验取样、追踪波次与德尔菲轮次

在参与者入组数小时到数周后开放调查会话,通过电子邮件、Twilio 短信或 Prolific 消息提醒参与者,并导出依从率和响应延迟。

v2.13.0 新增

配置 schedule: 区块后,Potato 会在参与者首次访问之后开放简短的调查会话;配置 notifications: 区块后,每个会话开放时会通过电子邮件、短信或 Prolific 消息发出通知。调查研究者用追踪波次开展纵向样本组研究,心理学和健康研究者用经验取样做日记研究和情绪研究,专家组则用德尔菲轮次就评价标准或指南达成共识。对每个会话,Potato 都会记录答案、提示发出的时间、参与者首次操作表单的时间,以及该会话是已作答、已过期还是已错过。

经验取样法(ESM)在一天中多次向参与者提出同样的几个简短问题,询问的是每次提示那一刻的状态。健康研究者把同样的设计称为生态瞬时评估(EMA)。在德尔菲法中,一个专家组分多轮对同一批条目评分,每次重新评分前都会看到上一轮专家组的匿名答案。

  • 时区。 每位参与者在入组时选择一个时区,入组页面会预先填入浏览器报告的时区。"09:00" 这样的时钟时间按参与者所在时区放在每个当地日期上。
  • 渠道。 消息可以经任意 SMTP 服务器以电子邮件发送,经 Twilio 以短信发送,或发送到参与者的 Prolific 收件箱。凭据只从环境变量读取。
  • 联系方式。 Potato 只有在参与者对知情同意问题回答“是”之后才会索取电子邮件地址或电话号码,并把这些地址保存在输出目录之外,不进入任何导出。

入组与会话计划

每位参与者只经历一次以下步骤:

  1. 参与者完成常规流程,包括知情同意、说明、标注和问卷。
  2. 流程结束时,入组页面询问时区;只有在参与者对同意问题回答“是”之后,才索取电子邮件地址或手机号码。被筛查淘汰、未通过培训或被注意力检查拦下的参与者不会入组。
  3. Potato 在那一刻为该参与者算出每个会话和每条提醒,并保存这份计划。
  4. 一个后台循环在每条消息的到期时间发送它,消息附带续答链接,点开即可登录并打开对应会话。
  5. 参与者用研究链接回来时,会进入当前开放的会话,或进入一个状态页,页面按参与者自己的时区显示下一个会话何时开放。

计划和时区在入组时就已确定。之后修改 waves 只影响此后入组的参与者。拒绝被联系的参与者仍然会入组,只是收不到消息,需要自己回到研究链接查找开放的会话。

配置

下面的配置运行一项为期七天的经验取样研究,每天在 09:00 到 21:00 之间的随机时间发出五次提示。常规流程是一个知情同意页面和一道入组问题,参与者答完这道入组问题即完成入组:

yaml
annotation_task_name: "Daily mood study"
task_dir: .
output_annotation_dir: annotation_output
data_files: [data/intake.jsonl]
item_properties: {id_key: id, text_key: text}
user_config:
  allow_all_users: true
 
phases:
  order: [consent, annotation]
  consent:
    type: consent
    file: surveys/consent.json
  annotation:
    type: annotation
 
annotation_schemes:            # the intake questions, answered once
  - annotation_type: likert
    name: usual_mood
    description: "In general, how would you describe your mood?"
    size: 7
    min_label: "Very low"
    max_label: "Very good"
 
schedule:
  timezone: "America/Detroit"  # used when the browser reports no time zone
  seed: "daily-mood-2026"
  experience_sampling:
    title: "How are you right now?"
    days: 7
    start_day: 1               # first prompts on the day after enrollment
    daily_window: "09:00-21:00"
    signal: {per_day: 5, min_gap: 90m}
    expires_after: 30m
    questions:
      - {name: mood, annotation_type: likert, size: 7, required: true,
         description: "How do you feel right now?",
         min_label: "Very bad", max_label: "Very good"}
      - {name: company, annotation_type: radio, required: true,
         description: "Who are you with?",
         labels: ["Alone", "Family", "Friends", "Colleagues", "Strangers"]}
    reminders:
      - at: open
      - after_open: 15m
 
notifications:
  dry_run: true                # pilot: render and log every message, send none
  base_url: "https://mood-study.example.org"
  channels:
    - type: log
    - type: twilio
      account_sid_env: TWILIO_ACCOUNT_SID
      auth_token_env: TWILIO_AUTH_TOKEN
      from_number: "+12025550143"
  contact:
    consent: {question: contact_consent, value: "Yes"}

这两条提醒规则在提示开放时发出提示,并在 15 分钟后给尚未作答的参与者再发一条消息。设置 dry_run: true 时,Potato 会渲染每条 Twilio 消息并填好收件人,在管理页面上逐条列出,但一条也不投递。试点结束后设置 dry_run: false。随机提示时间由 seed、参与者 ID 和日期共同决定。每位参与者的抽取彼此独立,事后也可以重新算出同样的结果。

surveys/consent.json 中的知情同意页面会提出 notifications.contact.consent 所指定的问题:

json
[
  {"name": "study_consent", "annotation_type": "radio",
   "description": "I agree to answer short surveys five times a day for seven days.",
   "labels": ["I agree", "I do not agree"],
   "label_requirement": {"required_label": ["I agree"]}},
  {"name": "contact_consent", "annotation_type": "radio",
   "description": "May we text you when each survey opens? Your number is kept apart from your answers and deleted when the study ends.",
   "labels": ["Yes", "No"],
   "label_requirement": {"required": true}}
]

启动时,Potato 会检查 contact_consent 是否在某个阶段页面上被提出,以及 Yes 是否是该问题的选项之一。YAML 中要给 "Yes" 加引号,因为 YAML 1.1 会把不带引号的 Yes 读成布尔值,而同意值为 true 或 false 会导致校验失败。value 也可以是一个列表,列出所有允许联系的答案。

经验取样选项

experience_sampling 键默认值含义
days必填取样天数,1 到 366
start_day1第一个取样日,从入组当天起算,0 表示入组当天
daily_window"09:00-21:00"提示所在的当地时间窗口
signal.per_day使用 signal 时必填每天的随机提示次数,1 到 48
signal.min_gap60m两次提示之间的最短间隔,不小于 expires_after
interval.every无在窗口开始时发一次提示,此后每隔该间隔再发一次
interval.times无固定的当地时钟时间,例如 ["10:00", "14:00", "20:00"]
expires_after30m每次提示保持开放的时长,至少一分钟
questions、questions_file二者必选其一提示中的问题(见下文的会话问题一节)
reminders[{at: open}]at: open、after_open 和 before_close。before_open 会被拒绝
title"Short survey"显示在提示页面和消息中
intro无问题上方的文字

一项研究只能在 signal(信号触发,随机时间)和 interval(间隔触发,固定时间)中选用一个。interval 区块只能在 every 和 times 中选用一个。时长写作 30m、48h、7d 或 2w,也可以写成秒数。

无法放进每日窗口的计划会校验失败。五次提示、间隔 90 分钟,需要四段 90 分钟的间隔,再加上最后一次提示保持开放的 30 分钟,也就是窗口中的 6 小时 30 分钟。同样间隔下九次提示需要 12 小时 30 分钟,在 12 小时的窗口中会校验失败。interval.every 长于窗口,或固定时间之间的间隔小于 expires_after,也会以同样的方式失败。

Potato 对每一天都把每日窗口放在参与者所在时区的该当地日期上。因此在时钟调整的那一天,窗口仍然是当地时间 09:00 到 21:00。随机时间在所有满足两个条件的排列中均匀抽取:相邻提示至少间隔 min_gap,最后一次提示在窗口内关闭。状态页面显示当天的窗口,从不显示下一次随机提示的时间。入组时已经过去的提示时间会被略去。

追踪波次

纵向样本组研究使用 waves。下面的部分区块替换上文配置中的 schedule,增加一周和一个月两次追踪:

yaml
schedule:
  timezone: "America/Detroit"
  waves:
    - name: week1
      title: "One-week follow-up"
      offset: 7d
      at: "09:00"              # local clock time on day 7
      window: 48h
      questions:
        - {name: changed_view, annotation_type: radio, required: true,
           description: "Since last week, has your view changed?",
           labels: ["No", "A little", "A lot"]}
      reminders:
        - before_open: 12h
        - at: open
        - before_close: 12h
      completion_code: "FOLLOWUP-DONE"
    - name: month1
      offset: 30d
      at: "09:00"
      window: 72h
      questions_file: surveys/followup.json

设置 at 后,即使中间时钟有调整,每个波次也会在指定那天的当地时间 09:00 开放,此时 offset 必须是整数天。不设置 at 时,offset: 7d 表示经过七天的实际时长,夏令时切换会让当地开放时间移动一小时。"09:00" 这样的时钟时间要加引号。YAML 1.1 会把不带引号的 10:30 读成数字 630,而 Potato 会把 630 理解为午夜后的分钟数。

waves 键默认值含义
name必填简短 ID,最多 40 个字母、数字、_ 或 -,用于导出
title取 name显示给参与者,也用于消息
offset0距入组的时间
at无入组后第 offset 天的当地时钟时间
window必填波次保持开放的时长,至少一分钟
questions、questions_file二者必选其一该波次的问题
reminders[{at: open}]消息何时发出
completion_code无参与者提交该波次后显示的代码,例如用于在第二项 Prolific 研究中领取报酬
intro无问题上方的文字

德尔菲轮次

德尔菲研究使用 delphi。下面的部分区块替换经验取样配置中的 schedule,data/criteria.json 保存一个由 {"id", "text"} 条目组成的列表:

yaml
schedule:
  timezone: "UTC"
  delphi:
    title: "Guideline criteria"
    items_file: data/criteria.json
    scale: {min: 1, max: 9, min_label: "Not important", max_label: "Essential"}
    comments: true
    max_rounds: 3
    round_length: 72h
    gap: 12h
    start: "2026-11-16T09:00:00-05:00"
    min_panel: 3
    stop:
      iqr_at_most: 1
      changed_share_below: 0.15
    reminders:
      - at: open
      - before_close: 24h

各轮次对整个专家组统一进行。第 1 轮在 start 开放;未设置 start 时,在使用该配置的服务器首次启动时开放,Potato 会在重启后保留这个时间。之后每一轮在上一轮关闭 gap 之后开放。在第 2 轮期间入组的专家组成员直接加入第 2 轮,能看到第 1 轮的反馈,但其中没有自己的评分。停止接收消息的专家组成员仍会收到每一轮,只是没有提醒。

从第 2 轮起,每个条目会显示上一轮的以下信息:

  • 中位数,以及中间一半评分(Q1 到 Q3)在量表上的阴影带,并标出该成员自己的评分。
  • 评分人数。
  • 其他专家组成员的匿名评论,顺序对每位成员固定。成员自己的评论不会回显给本人。

评分人数少于 min_panel 的条目不显示汇总,也不显示评论。有了这个阈值,小型专家组的评分就无法追溯到某一个人。当前轮次的评分从不预先填入。

当一轮在其时间窗口结束时关闭,或管理员在 /admin/schedule 上提前关闭该轮时,Potato 会计算每个条目的 n、中位数、均值、Q1、Q3 和四分位距(IQR)。四分位数在顺序统计量之间做线性插值,也就是 R 中的 type 7 方法,同时也是 NumPy 的默认方法。从第 2 轮起,Potato 还会计算 median_change 和 changed_share。median_change 是中位数变化的绝对值,changed_share 是在两轮中都给该条目评过分的成员里改变了评分的比例。一个条目满足至少一项已配置的标准时,即视为已稳定:

stop 键类型视为已稳定的条件
iqr_at_most共识IQR 不超过该值
median_change_at_most稳定性,从第 2 轮起中位数相比上一轮的变动不超过该值
changed_share_below稳定性,从第 2 轮起改变评分的成员比例低于该值,取值 0 到 1

研究在所有条目都已稳定的第一轮之后停止,或在达到 max_rounds 后停止。没有 stop 区块时,研究会跑满 max_rounds 轮。停止原因会显示在专家组成员的状态页面和管理页面上。

delphi 键默认值含义
items、items_file二者必选其一内联的 [{id, text}],或一个 JSON、JSONL 或 YAML 文件
scale{min: 1, max: 9}整数评分量表,最多 21 个点,可选 min_label 和 max_label
commentstrue每个条目一条可选评论,最多 2,000 个字符,匿名展示给专家组
max_rounds3轮数上限,1 到 10
round_length必填每一轮开放的时长
gap0一轮关闭到下一轮开放之间的时间
start服务器首次启动时第 1 轮何时开放,采用带 UTC 偏移的 ISO 8601 格式
min_panel3显示一个条目的汇总和评论所需的评分数
stop无停止标准
reminders[{at: open}]消息何时发出
title"Delphi round"显示在每轮页面上,后接轮次编号
intro无条目上方的文字

会话问题

波次和提示中的问题写法与标注方案相同,可以内联写在 questions 下,也可以放在一个 JSON 或 YAML 的 questions_file 中,路径相对于配置文件。每个问题都需要 name、annotation_type、description,以及 required: true 或 label_requirement: {required: true} 二者之一。六种问题类型接受以下键:

类型键与默认值
radiolabels(至少两个)、horizontal
multiselectlabels(至少两个)
likertsize(2 到 11)、min_label、max_label
slidermin_value(0)、max_value(100)、starting_value(取 min_value)、step(1)、min_label、max_label
numbermin_value、max_value、integer
textmultiline(true)、max_length(1000,最多 4,000)

使用该类型列表以外的键会校验失败。与 slider 标注方案不同,会话中的 slider 接受 min_label 和 max_label。滑块只有在参与者移动过之后才算已作答,服务器会拒绝超出问题选项或取值范围的答案。Potato 按会话把会话答案保存在 <output_annotation_dir>/scheduling/schedule.sqlite 中,而不是 user_state.json 中。在上文的配置中,一位参与者对每道提示问题最多可以作答 35 次,每次提示一次。

提醒

每个波次、经验取样区块和德尔菲区块都接受一个 reminders 列表,列表由四种规则组成:

规则消息何时发出
{at: open}窗口开放时
{before_open: 12h}窗口开放前 12 小时
{after_open: 2h}窗口开放后 2 小时,前提是该会话尚未作答
{before_close: 12h}窗口关闭前 12 小时,前提是该会话尚未作答

会话已作答或已关闭时,Potato 会跳过提醒;会话开放后,也会跳过 before_open 提醒。在参与者入组之前就已到期的提醒不会发送。after_open 或 before_close 的偏移量必须短于窗口。经验取样不接受 before_open,因为那条消息会提前透露一次随机提示。

通知

下面的部分区块替换上文配置中的 notifications,通过电子邮件和 Prolific 收件箱发送消息,并为提示设置了自定义文本:

yaml
notifications:
  base_url: "https://mood-study.example.org"
  channels:
    - type: log
    - type: smtp
      host: smtp.example.org
      port: 587
      security: starttls        # starttls, ssl, or none
      username_env: POTATO_SMTP_USER
      password_env: POTATO_SMTP_PASSWORD
      from: "Mood study <mood-study@example.org>"
    - type: prolific
      token_env: PROLIFIC_API_TOKEN
  templates:
    prompt:
      subject: "{study}: a short survey is open"
      body: "Please answer within {time_left}: {link}\n\nStop messages: {stop_link}"
  contact:
    consent: {question: contact_consent, value: "Yes"}
    delete_after_study: true

每种渠道类型最多出现一次:

渠道投递目标键与默认值
log服务器日志和管理页面,含完整文本和链接。不向参与者投递任何内容无
smtp入组时填写的电子邮件地址host(必填)、from(必填)、security(starttls)、port(587;使用 ssl 时为 465)、username_env 和 password_env(两个都设或都不设)、timeout(20 秒)
twilio入组时填写的手机号码,以短信发送account_sid_env(TWILIO_ACCOUNT_SID)、auth_token_env(TWILIO_AUTH_TOKEN)、from_number(一个 E.164 号码)和 messaging_service_sid_env 二者必选其一、timeout(20)
prolific参与者的 Prolific 收件箱,按 Prolific 参与者 ID 发送token_env(PROLIFIC_API_TOKEN)、study_id(未设置时取参与者的 STUDY_ID)、timeout(30)

Prolific 渠道只能联系到从 Prolific 来的参与者。Potato 在发送消息时才读取每个 *_env 键所指定的变量。如果 YAML 在 password、token 或 account_sid 这类键下直接写了凭据,Potato 会在启动时停止。缺少的变量会在启动时记入日志,之后该渠道上的每条消息都会失败,并报出该变量的名称。启动 Potato 之前,先在服务器环境中设置这些变量:

bash
export POTATO_SMTP_USER="mood-study@example.org"
export POTATO_SMTP_PASSWORD="<your SMTP password>"
export PROLIFIC_API_TOKEN="<your Prolific API token>"
notifications 键默认值含义
dry_runfalse在每个渠道上渲染每条消息并填好收件人,将每条消息记为 dry_run,但不投递
base_urlhttp://localhost:<port>链接中使用的公开地址。除非开启了 dry_run,否则使用 log 以外的任何渠道时都必填
channels无上表中的渠道
contact.consent配置渠道时必填{question, value},即阶段页面上的同意问题和允许联系的答案
contact.delete_after_studytrue参与者不再有剩余会话时,删除其联系方式
templates内置每类消息的文本

模板类别有 before_open、open、reminder(用于 after_open 和 before_close)、prompt(经验取样提示的开放消息)和 delphi_round。每个类别都接受 subject 和 body。占位符有 {study}、{session}、{opens}、{closes}、{time_left}、{link} 和 {stop_link}。Potato 按参与者的时区显示 {opens} 和 {closes},模板中出现未知占位符时会在启动时停止。短信只使用正文,上限是 Twilio 的 1,600 个字符;Prolific 消息会把主题和正文合在一起。

Potato 在发送每条消息之前先在数据库中认领它,因此即使经历重启,或有第二个服务器进程,也不会把同一条消息发送两次。发送中途被打断的消息会被标记为失败,不会重发。遇到临时性错误时,只要会话仍然开放,Potato 会在 5 分钟后和再过 10 分钟后重试,总共最多尝试三次。临时性错误指 SMTP 连接错误,以及 Twilio 或 Prolific 返回的 429 或 5xx 响应。其他错误会立即判为失败。

全局调度设置

schedule 键默认值含义
timezoneUTC浏览器未报告时区时,入组页面预选的 IANA 时区
seed空混入随机提示时间和德尔菲评论顺序的文本
poll_seconds30后台循环的运行频率,1 到 3,600 秒
schedulertrue随服务器运行后台循环
waves、experience_sampling、delphi无至少需要一个

后台循环负责发送到期的消息、开放和关闭德尔菲轮次,以及删除已完成参与者的联系方式。设置 scheduler: false 时,在管理员于 /admin/schedule 上按下 Send due messages now 或向 /admin/api/schedule/tick 发送 POST 请求之前,不会发出任何消息。只有 notifications 区块而没有 schedule 区块会校验失败。

续答链接与联系方式

续答链接(/s/<token>)会让参与者登录并打开一个会话,在该会话关闭时失效。链接用 POTATO_SCHEDULE_LINK_KEY 以 HMAC-SHA256 签名;未设置该变量时,使用一次性生成并保存在 <task_dir>/.potato/scheduling/link.key 中的随机密钥。服务器可能在全新磁盘上重启时,应设置该变量。参与者停止接收消息时,Potato 会让此前发给该参与者的所有链接失效。

电子邮件地址和电话号码保存在 <task_dir>/.potato/scheduling/contacts.sqlite 中,文件权限为 0600,位于标注输出目录之外。没有任何导出器、数据归档或部署包会读取这个联系方式存储,通知日志也只记录渠道,从不记录地址。如果主机每次重启都会清空磁盘,地址就会丢失,该主机上的参与者也不会再收到消息。

参与者可以在状态页面上,或通过每条消息中的退订链接停止接收消息。退订链接打开的是一个确认页面,只有页面上的按钮才会删除联系方式,因此打开链接的邮件扫描程序不会让任何人退订。设置 delete_after_study: true 时,参与者没有开放中或即将开放的会话后,其联系方式就会被删除;德尔菲研究则会保留联系方式,直到专家组完成全部轮次。

错过与过期的会话

会话在其时间窗口结束时关闭,服务器会拒绝关闭后提交的答案。Potato 把已关闭的会话记为以下三种状态之一:

  • answered(已作答)表示参与者在关闭前提交了该会话。
  • expired(已过期)表示参与者打开了会话或开始作答,但在关闭前没有提交。
  • missed(已错过)表示会话开放期间参与者一次也没有打开过。

Potato 不会为已关闭的会话重新安排时间,该会话尚未发出的提醒也会被跳过。参与者打开旧链接时,会看到该会话何时关闭、下一个会话何时开放;如果下一个会话是随机提示,则不显示开放时间。依从率等于已作答会话数除以已关闭会话数,按参与者和会话类型分别计算。

管理页面

访问 /admin/schedule 需要管理员 API 密钥或 RBAC 管理员角色。页面显示每位参与者的时区、同意情况、依从率、响应延迟中位数和下一个会话,按状态列出每条消息,并给出 log 和 dry_run 消息的完整文本。德尔菲轮次会连同条目统计一起显示。页面上的按钮对应下面的 POST 端点:

端点方法作用
/admin/api/scheduleGET以 JSON 返回页面数据,不含地址
/admin/api/schedule/tickPOST立即发送到期的消息
/admin/api/schedule/delphi/close_roundPOST关闭当前开放的德尔菲轮次
/admin/api/schedule/contact/<participant>/deletePOST删除一位参与者的联系方式

导出

会话答案不是条目标注,主导出不包含会话答案。schedule 导出改为写出若干附属表,每张表都有一个 participant 列,其 ID 与主导出中的 user_id 相同:

bash
python -m potato.export --config config.yaml --format schedule --output out/
文件行
schedule_sessions.csv每位参与者的每个会话一行,另有每位参与者一行 enrolment,对应常规流程
schedule_compliance.csv按参与者和会话类型各一行,含 sessions、closed、answered、expired、missed、compliance、median_latency_seconds
schedule_notifications.csv每条计划中的消息,含 session_id、rule、template、channel、due_at_utc、status、attempts、sent_at_utc、detail。不含地址,也不含消息文本
delphi_ratings.csv仅限德尔菲研究,含 participant、round、item、rating、comment
delphi_rounds.csv仅限德尔菲研究,按轮次和条目各一行,含 n、median、q1、q3、iqr、mean、median_change、changed_share、settled、opens_at_utc、closed_at_utc、round_status、stop_reason

德尔菲评分写入 delphi_ratings.csv,各轮会话出现在 schedule_sessions.csv 中,带有状态和响应延迟。schedule_sessions.csv 的列如下:

列含义
kindwave、prompt、round 或 enrolment
session_id、wave、day、prompt、round、title该行对应哪个会话。day 是从 1 起算的取样日,prompt 是当天的第几次提示
timezone参与者的时区
opens_at_utc、closes_at_utc、opens_at_local、closes_at_local时间窗口,分别以 UTC 和当地时间表示
statusanswered、expired、missed、open、started 或 upcoming(入组行为 enrolled)
prompt_at_utc开放消息发出的时间;未发送消息时为窗口开始时间
opened_at_utc、first_answer_at_utc、submitted_at_utc参与者打开页面、首次操作表单和提交的时间
latency_seconds从 prompt_at_utc 到首次作答
response_seconds从首次作答到提交
reminder_channels参与者接收消息的渠道,位于入组行
answer.<name>每个问题一列,多选答案用 ; 连接

示例

bash
python potato/flask_server.py start examples/advanced/experience-sampling/config.yaml -p 8000

这个示例用 log 渠道和一个空跑(dry run)的 Twilio 渠道运行上文的取样计划。/admin/schedule 上的管理页面会列出每次提示的续答链接。examples/advanced/two-wave-panel/ 和 examples/advanced/delphi-study/ 两个文件夹分别包含一个带一周追踪的样本组研究和一个三轮德尔菲研究。

延伸阅读

实现细节请参阅源文档。