mcpField
D
data-warehouse-experimentation

data-warehouse-experimentation

github.com/rampstackco数据与数据库1.0k240 次安装1 周前更新
代码仓库

关于 data-warehouse-experimentation

面向成熟数据团队的数据仓库原生实验 Skill,指导团队在现有数据栈中运行实验,而非依赖专用平台。内容涵盖分配、曝光日志、dbt 指标,以及用 SQL 或 Python 进行统计分析。

主要功能

  1. 01评估数据仓库原生方案的适用性
  2. 02通过哈希、功能标记或分配表分配变体
  3. 03记录用户首次曝光事件
  4. 04在 dbt 模型中定义实验指标
  5. 05用 SQL 或 Python 执行统计分析

适用场景

  1. 01搭建数据仓库原生实验基础设施
  2. 02审查现有实验数据流程
  3. 03分析平台无法处理的自定义指标或细分

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rampstackco/claude-skills --skill data-warehouse-experimentation

安装后在 Claude Code 中输入/data-warehouse-experimentation确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
references/
assignment-and-exposure-patterns.md
common-pitfalls.md
metric-definitions-in-dbt.md
power-analysis-calculations.md
sequential-testing-patterns.md
statistical-analysis-templates.md
variance-reduction-techniques.md
warehouse-vs-platform-decision.md

详细介绍

数据仓库实验

资深数据科学家的实战指南:直接在 BigQuery、Snowflake 或任何现代数据仓库中开展实验,使用 dbt 定义指标,并通过 SQL 或 Python 进行统计分析。

大多数大规模开展实验的公司都会使用专用平台,例如 Statsig、Optimizely、集成实验功能的 LaunchDarkly、PostHog 和 Amplitude Experiment。这些平台表现出色,能在一个产品中处理分流、埋点和分析,因此精通 SQL 的数据团队无需从头重造方差缩减方法。

成熟的数据团队越来越倾向于选择另一种运营模式:原生于数据仓库的实验。分流通过代码或功能标志完成。曝光事件像其他事件一样写入数据仓库。指标定义为 dbt 模型。统计分析则通过 SQL 或 Python 笔记本,基于数据仓库中的数据运行。“实验平台”就是你现有的数据技术栈。

本技能介绍何时适合采用原生于数据仓库的实验、其架构,以及让它行之有效的具体技术:分流模式、曝光日志规范、dbt 中的指标定义、用 SQL 进行 t 检验和 CUPED、序贯检验,以及可能导致自建方案失败的陷阱。

何时使用本技能:在平台方案与数据仓库原生方案之间做出选择、构建数据仓库原生实验基础设施、审查现有基础设施,或在权威平台无法处理自定义指标或细分时开展特定实验。


本技能的用途

本技能涵盖数据仓库原生实验的运营执行模式。它并不取代方法论和结果解读技能,而是与它们配合使用。

  • experiment-design 涵盖方法论:假设、样本量、随机化单位、主要指标。与工具无关。请先阅读它,以便正确设计实验,无论实验在哪里运行。
  • experimentation-analytics 涵盖结果解读:置信区间、p 值、效应量、决策框架。与工具无关。结果出来后请阅读它。
  • experimentation-platform-orchestrator 详细介绍平台方案与数据仓库方案之间的选择。请阅读它,以决定使用平台还是本技能。
  • feature-flagging 涵盖非平台场景下的分流基础设施。请阅读它,了解本技能默认采用的功能标志管理规范。
  • 本技能(data-warehouse-experimentation)涵盖运营执行:基于 SQL 的分流、曝光日志、dbt 中的指标定义、通过 SQL 或 Python 进行统计分析、方差缩减和序贯检验。

两者的区别在于“做什么”(方法论和结果解读技能)以及“如何在没有供应商平台的情况下实现”(本技能)。确定数据仓库原生方案适合你之后,再阅读本技能。如果你仍在做选择,请从 experimentation-platform-orchestrator 开始。


适合采用数据仓库原生方案的情况

有六个因素会让决策倾向于数据仓库原生方案。

  1. 大规模下的成本。 平台按 MAU 或事件数收费。MAU 为 10K 时,平台成本很低;MAU 达到 1M 时,账单就会成为一项实实在在的预算支出。数据仓库原生方案运行在你已经付费使用的基础设施上。
  2. 自定义指标。 如果你的主要指标是复杂的业务指标(考虑退款逻辑的收入、群组 LTV、留存区间、多事件复合指标),平台可能难以应对。数据仓库原生方案可以用 SQL 表达任何你能编写的指标。
  3. 自定义细分。 企业客户、账户层级交叉分析、复杂的行为细分。平台提供细分功能,但深度各不相同。dbt 模型的组合没有限制。
  4. 可信要求。 受监管行业(医疗保健、金融、政府)需要完全透明地了解计算过程。数据仓库原生方案让你可以在 SQL 中审计计算的每个步骤。
  5. 现有数据团队的实力。 如果你有数据工程师和数据科学家,就已经具备大部分基础设施。在此基础上加入实验规范,成本低于采用新的平台。
  6. 指标定义的迭代。 平台按自己的节奏发布指标更新。数据仓库原生方案可以随着 dbt 部署快速迭代。

有五个因素会让决策倾向于平台。

  1. 前端视觉实验。 这是 Optimizely 的看家本领。通过 script 标签注入变体代码,并提供 WYSIWYG 编辑功能。
  2. 不到一周的迭代速度。 有些平台可以在 30 分钟内设置好实验;而数据仓库原生方案首次运行新的指标模式,通常需要一天或更久。
  3. 缺乏强大数据基础设施的团队。 如果你没有数据仓库、dbt 和分析师,就不要从数据仓库原生方案开始。平台才是合适的选择。
  4. 移动端实验。 通过基于 SDK 的分配实现离线支持,是平台的职责,而不是数据仓库的职责。
  5. 开箱即用且具有严格保证的序贯检验。 Statsig 和 Eppo 提供经过校准的 alpha-spending mSPRT。在内部构建这套机制需要投入大量工作。

详细说明和决策树见 references/warehouse-vs-platform-decision.md。许多成熟团队会同时使用两种方案:难度较高的情况采用数据仓库原生方案,标准实验的快速迭代则使用平台。


架构

四个组件,按数据流顺序排列。

  1. 分配。 如何将用户分配到不同变体。哈希函数、功能标志或随机分配表。
  2. 曝光日志。 用户首次接触实验时触发的离散事件,像其他事件一样写入数据仓库。
  3. 指标定义。 根据数据仓库中的事件计算主要和次要指标的 SQL 查询(或 dbt 模型)。
  4. 分析。 使用 SQL 或 Python 进行统计计算,将曝光数据与指标关联,并生成带有置信区间的效果估计。

数据流如下:用户访问产品。分配机制决定其所在分组(对照组或处理组)。如果用户接触到该变体(看到仅在处理组中出现的行为),就会向数据仓库发送曝光事件。用户执行操作,并向同一个数据仓库生成指标事件。在分析时,根据分配单位(通常是 user_id)将曝光数据与指标关联;分析会计算提升幅度并给出决策。

曝光事件模式至关重要。没有它,你只能进行“意向治疗”分析(分析所有被分配的用户,无论他们是否看到该变体)。有了它,你就可以针对实际体验过该变体的用户群体进行“已曝光”分析。后者通常才是你想要的,尤其是当变体只会影响部分已分配用户时(例如,到达特定页面的用户)。


分配模式

三种方法。

确定性哈希分配。 仓库原生方案的默认选择。

MOD(ABS(FARM_FINGERPRINT(CONCAT(user_id, 'exp_button_color_v1'))), 100) < 50

盐值('exp_button_color_v1')可确保同一用户在不同实验中的分配互不相关。结果可复现(相同输入始终产生相同分桶结果),无需依赖服务,且不同实验之间的盐值相互隔离。可在任何 SQL 查询中内联计算分配结果。

功能标志分配。 依赖功能标志服务(LaunchDarkly、Statsig flags、Unleash、内部服务)执行分桶;数据仓库只记录标志服务选定的分配结果。

-- Read assignment from the flag service's logs
SELECT user_id, variant_id, assigned_at
FROM flag_service.assignments
WHERE flag_key = 'exp_button_color_v1'

当标志服务是分配结果的事实来源,且数据仓库会镜像分配表时,这种方式就适用。如果分配必须遵循标志服务规则(例如百分比发布、定向规则),而这些规则不便于在 SQL 中复现,那么这种方式就很有用。

随机分配表。 在实验开始时,预先将用户随机分配到一张表中。

CREATE TABLE exp_button_color_v1_assignments AS
SELECT
  user_id,
  CASE WHEN RAND() < 0.5 THEN 'control' ELSE 'treatment' END AS variant_id
FROM dim_users
WHERE eligible = true;

这种方式不太常见;当实验开始时符合条件的用户集合是固定的,且你希望分配结果明确、可复现(例如用于合规审计)时,它就很有用。缺点是:实验期间新加入的用户不会出现在表中;你可以跳过他们,或改用哈希分配。

确定性哈希方法是仓库原生方案的默认选择,因为它无需依赖服务,且能生成稳定、可审计的分配结果。详见 references/assignment-and-exposure-patterns.md。


曝光日志

仓库原生实验中最重要的一项规范。

必需的曝光事件架构:

字段类型说明
experiment_idstring每个实验的唯一标识符。
variant_idstring用户被分入的变体。
user_idstring分配单位。
exposed_attimestampISO 8601 UTC。触发曝光的时刻。
context_*various可选的上下文属性:设备、页面、account_id。

用户看到特定变体的行为时,立即触发曝光。不要在页面加载时触发。不要在会话开始时触发。不要在应用打开时触发。

“延迟曝光”陷阱。如果变体只在按钮点击时才会产生影响,而你在页面加载时触发曝光,那么所有加载过页面的用户都会进入分析,不管他们是否真的看到过该变体。对照组会包含从未看到按钮的用户;实验组也一样。分析结果会低估真实效果。

示例。实验组展示新的定价页面;对照组展示旧页面。应在定价页面加载时触发曝光,而不是在用户进入首页时触发。从未到达定价页面的用户没有接触任何一个变体;不应将他们纳入分析。

“始终触发”陷阱。有些实现会在每次与特定变体交互时都触发曝光。用户点击按钮五次;曝光就会触发五次。曝光日志会比应有规模大五倍;会统计曝光中不同 user_id 数量的分析工具可以正确处理这种情况,而统计行数的工具则不行。

规范。每位用户在每个实验中只触发一次曝光事件,并在首次接触特定变体时触发。在客户端使用确定性标志(或使用服务端缓存)来确保只触发一次。详见 references/assignment-and-exposure-patterns.md。


dbt 模型中的指标定义

将指标定义为 dbt 模型,可以带来四项好处。

  1. 指标定义的版本控制。 指标的每次变更都会成为一次 git 提交。历史记录可供查询。
  2. 可测试性。 对指标输出运行 dbt 测试,可以发现回归问题。
  3. 可组合性。 同一个 fct_orders 模型可同时为看板、实验分析和高管报告提供数据。定义一致,不会出现偏差。
  4. 单一事实来源。 当实验结果与看板结果不一致时,答案就在 dbt 模型中,而不是两个互不相关的 SQL 文件里。

模式。

-- models/experiments/exp_metrics_revenue.sql
SELECT
  user_id,
  SUM(CASE WHEN refunded THEN 0 ELSE amount_cents END) AS net_revenue_cents,
  MIN(occurred_at) AS first_purchase_at
FROM {{ ref('fct_orders') }}
WHERE occurred_at >= '{{ var("experiment_start") }}'
GROUP BY user_id

实验分析会将其与曝光日志按 user_id 连接,并计算各组均值。

方差纪律。看板和实验分析使用相同的指标定义。不要使用略有不同的“实验专用收入计算”。否则就会出现“实验显示收入提升了,但看板没有变化”的问题,而这几乎总是指标定义不一致所致。

命名空间模式。使用 exp_metrics_* 表示按 user_id 分组、每位用户生成一行的实验型模型。使用 fct_* 表示为指标模型和看板提供数据的底层事实表。详情见 references/metric-definitions-in-dbt.md。


SQL 中的统计分析

SQL 中的基本双样本 Welch t 检验。

WITH metric_by_variant AS (
  SELECT
    e.variant_id,
    COUNT(*) AS n,
    AVG(m.net_revenue_cents) AS mean,
    VAR_SAMP(m.net_revenue_cents) AS variance
  FROM exposures e
  LEFT JOIN exp_metrics_revenue m USING (user_id)
  WHERE e.experiment_id = 'exp_button_color_v1'
  GROUP BY e.variant_id
)
SELECT
  control.mean AS control_mean,
  treatment.mean AS treatment_mean,
  treatment.mean - control.mean AS absolute_lift,
  (treatment.mean - control.mean) / NULLIF(control.mean, 0) AS relative_lift,
  -- Welch's t-statistic
  (treatment.mean - control.mean) /
    SQRT(treatment.variance / treatment.n + control.variance / control.n)
    AS t_statistic
FROM
  (SELECT * FROM metric_by_variant WHERE variant_id = 'control') control,
  (SELECT * FROM metric_by_variant WHERE variant_id = 'treatment') treatment

使用 SQL 函数将 t 统计量转换为 p 值或置信区间(BigQuery:UDF;Snowflake:原生函数或存储过程),也可以对 SQL 查询结果使用 Python 进行计算。

对于连续指标的简单 t 检验,SQL 模式足以胜任。对于比例检验,将方差替换为 p * (1 - p)。对于非参数检验(Mann-Whitney),SQL 很快就会变得复杂;此时应改用 Python。

凡是比简单 t 检验更复杂的分析(CUPED、自助法、双重稳健估计、序贯检验),用 Python 会更容易。使用 SQL 进行 SUM 和 AVG 聚合;将结果发送到 Python 进行统计计算。详情见 references/statistical-analysis-templates.md。


Python 中的统计分析

Python 模式,通常在 Jupyter 或 Hex 笔记本中使用。

import pandas as pd
import numpy as np
from scipy import stats

# Pull aggregated data from the warehouse
df = warehouse.query("""
  SELECT user_id, variant_id, net_revenue_cents
  FROM exp_results
  WHERE experiment_id = 'exp_button_color_v1'
""")

control = df[df.variant_id == 'control'].net_revenue_cents
treatment = df[df.variant_id == 'treatment'].net_revenue_cents

# Welch's t-test
t, p = stats.ttest_ind(treatment, control, equal_var=False)

# Confidence interval on the mean difference
diff = treatment.mean() - control.mean()
se = np.sqrt(treatment.var() / len(treatment) + control.var() / len(control))
ci_low, ci_high = diff - 1.96 * se, diff + 1.96 * se

print(f"Lift: {diff:.2f} cents (95% CI: [{ci_low:.2f}, {ci_high:.2f}])")
print(f"p-value: {p:.4f}")

Python 可让你使用完整的统计生态系统(scipy、statsmodels、numpy),实现 SQL 难以表达的技术。

笔记本模式。每个实验对应一个笔记本,并通过 experiment_id 参数化。使用 git 进行版本控制,或将其作为 Hex 项目管理。每个笔记本最后都会生成一份书面决策文档,并归档到可查询的仓库中(Notion、GitHub Markdown,或数据仓库中专用的实验结果表)。

详情和自助法模板见 references/statistical-analysis-templates.md。


方差缩减:CUPED 及其他方法

仓库原生实验中最强大的方差缩减技术:CUPED(使用实验前数据进行对照实验)。最初由 Microsoft 提出。

其原理是:如果能根据实验前的数据预测用户的指标表现,就可以减去这部分预测方差,留下更小的残差进行检验。

# Pre-experiment metric for each user (e.g., last 28 days revenue)
pre = pre_period_revenue(user_id)

# Theta is the regression coefficient of the metric on the pre-period
theta = np.cov(metric, pre)[0, 1] / np.var(pre)

# Adjusted metric
adjusted_metric = metric - theta * (pre - pre.mean())

对调整后的指标而非原始指标进行 t 检验。均值保持不变(CUPED 不会改变点估计),但方差会减小,因此置信区间更窄。

CUPED 通常可将参与度指标的方差降低 30% 到 50%。这相当于将实验时长延长 1.5 倍到 2 倍,同时保持相同的统计功效。对于每季度运行 5 个以上实验的团队,值得投入工程资源。

其他方差缩减技术。

  • 分层。 按照实验前协变量(细分群体、地区、设备)切分分析,再汇总各层的估计值。当协变量对指标有很强的预测能力时,这种方法很有用。
  • 回归调整。 使用协变量拟合 OLS 回归;残差分析的方差更低。这种方法将 CUPED 扩展到了多个协变量。
  • 双重稳健估计。 将结果建模与倾向评分加权相结合。在随机化不完善的观察性研究和准实验中很有用。此方法超出典型 A/B 测试的范围;方差缩减参考文件中提供了学术参考资料。

带实际示例的详细说明见 references/variance-reduction-techniques.md。


实验前功效分析

开始实验前,计算所需样本量。

from statsmodels.stats.power import tt_ind_solve_power

# Solve for sample size given desired MDE
n_per_arm = tt_ind_solve_power(
    effect_size=0.05,  # Cohen's d
    nobs1=None,
    alpha=0.05,
    power=0.8
)

# Or solve for MDE given sample size
mde = tt_ind_solve_power(
    effect_size=None,
    nobs1=8000,
    alpha=0.05,
    power=0.8
)

“我们需要的用户数是预想的 10 倍”这一教训。大多数功效不足的实验都源于过于乐观的效应量假设。团队设计实验时预计提升 10%;但实际效应只有 1%,以计划样本量无法检测出来;实验要么无限期运行,要么以结果不明确告终。

解决办法:利用以往实验观测到的效应分布,设定切合实际的 MDE 预期。如果最近 30 个实验中观测到的效应中位数为 0.5%,就将新实验的 MDE 设为 0.5%。乐观预期中的不对称性确实存在;要纠正这一点,需要查看实际效应分布,而非期望中的分布。

详情见 references/power-analysis-calculations.md。


序贯检验模式

“窥探”问题。在实验完成前查看结果会抬高假阳性率。天真的解决办法是“不要窥探”。实际可行的解决办法是采用允许有效提前停止的序贯检验方法。

三种方法。

  • mSPRT(混合序贯概率比检验)。Optimizely 和 Statsig 使用的方法。可提供不会因窥探而失效的 p 值。可通过 statsmodels 或自定义代码在 Python 中实现;SQL 中没有原生实现。
  • 始终有效推断,采用置信序列。Howard 等人提出。置信区间在任意样本量下都有效。实现需要谨慎使用 Python;不适合尚未读过相关论文的数据团队。
  • 组序贯设计(O'Brien-Fleming 边界)。预先设定中期分析节点,并校准 alpha 消耗。这是经典的频率学派方法。

对于原生基于数据仓库的方案,实际建议是在 Python 中使用 mSPRT。记录所用的 alpha 消耗函数。让一位团队成员掌握相关数学原理;不要依赖黑盒实现。

实话实说。如果团队里没人理解序贯检验的数学原理,那就不要窥探。预先登记样本量,运行至完成,然后只分析一次。只有在正确实现时,序贯检验在统计学上才是正确的;错误的实现比完全不遵守不窥探原则更糟。

详见 references/sequential-testing-patterns.md。


常见陷阱

在原生基于数据仓库的实验中,常见 11 种模式。详见 references/common-pitfalls.md。

  • “我们的曝光日志在页面加载时触发。”应该在显示特定变体行为时触发,而不是页面加载时。“延迟曝光”陷阱会稀释效果。
  • “我们在对照组中看到提升,处理组中却没有。”可能是分配哈希碰撞或重复使用盐值。审计盐值;检查同一用户在不同实验中是否被分配到不同的桶。
  • “p 值是 0.04,我们要发布了。”很可能是统计功效不足、多重比较和窥探共同导致的。只在计划的样本量下计算检验;对多个次要指标进行校正。
  • “实验显示提升了 30%。”几乎肯定是 Bug。这么大的效果很少见;庆祝之前,首先应审计曝光日志和指标定义。
  • “处理组用户都不一样。”这是样本比例失配(SRM)。分配哈希有问题,或者曝光日志存在偏差。计算任何指标前先检查 SRM。
  • “我们无法复现昨天的数字。”可能是查询不确定性(窗口函数未显式指定 ORDER BY、抽样未设置种子),也可能是聚合中的浮点数问题。确保查询具有确定性;如果使用了随机种子,应记录下来。
  • “自定义指标定义和看板指标不一致。”这很糟糕。使用同一个 dbt 模型使它们保持一致,否则没人会相信这两个数字。
  • “我们从未完成实验。”预先登记停止标准,并遵守这些标准。无限期拖延的实验会浪费团队时间,并导致决策含糊不清。
  • “iOS 用户在处理组中的转化率是对照组的 3 倍。”可能是细分效应,也可能是埋点 Bug。检查 iOS 埋点是否与其他平台不同。谨慎对待小细分群体上的夸大结论。
  • “第 1 阶段有效,第 2 阶段却失效了。”这可能是由队列构成变化导致的辛普森悖论。不同阶段中底层细分群体的权重发生变化时,整体趋势就会反转。
  • “统计显著,但效果很小。”大样本提高了统计功效。p 值低于 0.05,但效果只有 0.3%。应考虑实际意义:这 0.3% 值得发布吗?

框架:仓库原生实验的 12 项考量

设计或运行仓库原生实验时,请逐项考量以下 12 项内容。

  1. 平台还是数据仓库。 成本、自定义指标、细分、可信度、团队实力。如果尚未决定,请阅读 experimentation-platform-orchestrator。
  2. 分配单位。 用户、账户、会话、设备。在实验开始时选定,并始终保持一致。
  3. 分配盐值。 每个实验使用唯一盐值,以防与之前的实验产生相关性。记录盐值的约定。
  4. 曝光日志规范。 在变体产生影响时触发,而不是在页面加载时触发。每位用户在每个实验中只记录一次曝光事件。
  5. SRM 检查。 样本比例不匹配表明分配存在错误。计算任何指标前都要检查。
  6. 复用指标定义。 看板和实验使用相同的 dbt 模型。不要采用会偏离规范指标的实验专用计算方式。
  7. 实验前功效分析。 根据历史已观测效应的分布,设定现实的 MDE。
  8. 方差缩减。 对参与度指标使用 CUPED。对于每季度运行 5 个以上实验的团队,30% 到 50% 的方差缩减值得投入工程资源。
  9. 统计方法。 默认使用 Welch's t-test。对于偏态分布,使用 Bootstrap。对于准实验,使用双重稳健估计。
  10. 序贯检验。 使用 mSPRT 或固定样本量停止。记录 alpha 消耗。如果无法正确实现,就不要偷看结果。
  11. 多重比较。 对次要指标使用 Bonferroni 或 Benjamini-Hochberg 校正。
  12. 决策记录。 写下结果,并归档到可查询的存储库中。下一次实验将受益于这份组织记忆。

该框架的产出是一份实验记录,其中包括预先注册的样本量和停止标准、分配盐值、曝光日志规范、dbt 指标模型、分析笔记本,以及书面记录的决策。该记录存放在版本控制系统或专用实验跟踪系统中;分析可根据记录重现。如果记录所需的输入不可用,请按照数据可用性规则说明缺口。


所需数据不可用时

此技能的输出依赖于其无法自行生成的数据、测量结果或工具结果。如果所需的输入、工具或数据源不可用或无法验证,获准的输出是注明缺口的交付内容:说明需要什么、实际获取或验证了什么,以及输出的哪些部分受到影响。绝不允许为了完成交付内容而编造、估算或插补所需数值。明确说明缺口即为完整答复。


参考文件


总结:自建还是购买,确实需要认真抉择

数据仓库原生实验能力很强,但会耗费大量工程时间。实验团队在成立的第一年几乎总是应该从平台起步;平台能处理 90 percent 的情况,让你专注于假设,而非基础设施。团队之所以转向数据仓库原生方案,是因为实验量、定制指标需求或信任要求超出了平台的能力范围。

如果你是因为“平台太贵”而构建数据仓库原生方案,却没有先算清成本:你低估了团队工程时间的成本。在采购申请表上看起来很贵的平台费用,往往比数据工程师花三个月时间重新实现 CUPED 更低。

如果你是因为平台无法满足特定需求而构建:你很可能是对的,这项投入也会带来回报。平台是通用的;你的业务却有其特殊性。平台无法表达的定制指标,往往正是对决策最重要的指标。

坦诚的折中方案是:许多成熟团队会同时采用两种方案。对于注重出结果速度、无需深入定制的标准实验,使用平台进行快速迭代;对于平台的指标库或细分能力无法满足的复杂情况,采用数据仓库原生方案。混合模式在运营上更复杂;请记录哪些实验采用哪种方案的规则,并每年重新评估。

评论

支持 Markdown