本章目标:这是全教程最核心的一章。 目标是让你达到"看到一段 SAS 程序,能在脑子里完成翻译"的程度。 建议做法:读完一遍后,找一段你手头的真实 SAS 程序,逐句重写。
8.1 总览对照表(建议打印/置顶)
| # | SAS 操作 | pandas 写法 | 章节 |
|---|---|---|---|
| 1 | where 条件 |
df[条件] / df.query() |
8.2 |
| 2 | if/then/else 派生 |
np.where / np.select |
8.3 |
| 3 | keep / drop |
df[[...]] / df.drop(columns=...) |
8.4 |
| 4 | rename |
df.rename(columns={}) |
8.4 |
| 5 | set(纵向堆叠) |
pd.concat([...]) |
8.5 |
| 6 | merge(横向连接) |
pd.merge() |
8.5 |
| 7 | PROC SORT |
df.sort_values() |
8.6 |
| 8 | nodupkey 去重 |
df.drop_duplicates() |
8.6 |
| 9 | PROC MEANS |
df.groupby().agg() |
8.7 |
| 10 | PROC FREQ |
value_counts() / crosstab() |
8.8 |
| 11 | PROC TRANSPOSE |
pivot() / pivot_table() / melt() |
第 09 章 |
| 12 | PROC SQL 子查询 |
df.query() / 链式筛选 |
8.9 |
| 13 | first. / last. |
groupby().transform() / drop_duplicates(keep=) |
8.10 |
| 14 | retain |
ffill() / groupby().cumsum() |
8.10 |
| 15 | lag() / dif() |
df.shift() / df.diff() |
8.10 |
| 16 | PROC RANK |
df.rank() |
8.7 |
| 17 | PROC FORMAT 自定义 |
pd.cut / map / replace |
8.11 |
| 18 | nobs / _N_ |
len(df) / df.index |
8.4 |
8.2 筛选(WHERE)
data adsl2;
set adsl;
where SAFFL = 'Y' and AGE >= 65;
run;
adsl2 = adsl[(adsl["SAFFL"] == "Y") & (adsl["AGE"] >= 65)]
# 或(推荐,可读性更好)
adsl2 = adsl.query("SAFFL == 'Y' and AGE >= 65")
常用条件对照表
| SAS WHERE | pandas |
|---|---|
x = 1 |
df["x"] == 1 |
x ^= 1 / x ne 1 |
df["x"] != 1 |
x in (1, 2) |
df["x"].isin([1, 2]) |
x not in (1, 2) |
~df["x"].isin([1, 2]) |
x > 1 and y < 2 |
(df["x"] > 1) & (df["y"] < 2) |
x > 1 or y < 2 |
(df["x"] > 1) \| (df["y"] < 2) |
x is missing |
df["x"].isna() |
x is not missing |
df["x"].notna() |
x between 1 and 2 |
df["x"].between(1, 2) |
x like '%abc%' |
df["x"].str.contains("abc", na=False) |
x =: 'AB'(前缀) |
df["x"].str.startswith("AB", na=False) |
index(x, 'AB') > 0 |
df["x"].str.contains("AB", na=False) |
upcase(x) = 'AB' |
df["x"].str.upper() == "AB" |
prxmatch('/^A/', x) |
df["x"].str.match(r"^A", na=False) |
x = . (数值) |
df["x"].isna() |
x = '' (字符) |
df["x"] == "" |
⚠️
na=False是必须的:.str.contains()遇到缺失值返回NaN, 而NaN在布尔索引里会报错。加na=False表示"缺失值视为不匹配"。
8.3 派生变量(IF/THEN/ELSE)
data adsl2; set adsl;
if AGE < 65 then AGEGR1 = '<65';
else if AGE <= 80 then AGEGR1 = '65-80';
else AGEGR1 = '>80';
/* 数值派生 */
BMI = WEIGHTBL / (HEIGHTBL/100)**2;
/* 字符函数 */
SITEID2 = substr(USUBJID, 4, 3);
run;
adsl2 = adsl.copy()
# 多条件分支
adsl2["AGEGR1"] = np.select(
[adsl2["AGE"] < 65, adsl2["AGE"] <= 80],
["<65", "65-80"],
default=">80",
)
# 数值派生
adsl2["BMI"] = adsl2["WEIGHTBL"] / (adsl2["HEIGHTBL"] / 100) ** 2
# 字符函数(pandas 的 .str 访问器 = SAS 字符函数)
adsl2["SITEID2"] = adsl2["USUBJID"].str.slice(3, 6) # substr(x, 4, 3)
📌
substr→.str.slice(start-1, end)的换算:substr(x, 4, 3)= 从第 4 个字符起取 3 个 = Python 索引 3 到 6 =.str.slice(3, 6)
SAS 字符函数 → pandas .str 方法对照
| SAS | pandas .str |
|---|---|
substr(x, 1, 3) |
.str.slice(0, 3) 或 .str[:3] |
upcase(x) |
.str.upper() |
lowcase(x) |
.str.lower() |
propcase(x) |
.str.title() |
strip(x) |
.str.strip() |
trim(x) |
.str.rstrip() |
left(x) |
.str.lstrip() |
length(x) |
.str.len() |
scan(x, 2, '-') |
.str.split("-").str[1] |
catx('-', a, b) |
a.str.cat(b, sep="-") |
tranwrd(x, 'A', 'B') |
.str.replace("A", "B") |
index(x, 'A') |
.str.find("A") |
compress(x) |
.str.replace(r"\s", "", regex=True) |
compress(x, '0123456789') |
.str.replace(r"\D", "", regex=True) |
repeat(x, 2) |
.str.repeat(2) |
reverse(x) |
.str[::-1] |
prxchange('s/a/b/', -1, x) |
.str.replace(r"a", "b", regex=True) |
put(x, $10.) |
.str.pad(10) |
💡
regex=True很关键:pandas 的.str.replace()默认是字面替换 (这点比 SAS 的tranwrd还安全),要用正则必须显式声明。
8.4 列操作(KEEP / DROP / RENAME / 长度)
data want;
set have (keep=USUBJID AGE SEX rename=(AGE=AGEBL));
length AGEGR1 $10;
run;
# keep + rename 一步完成
want = have[["USUBJID", "AGE", "SEX"]].rename(columns={"AGE": "AGEBL"})
# 用列表管理(临床程序推荐:把变量清单集中声明,便于 QC)
KEEP_VARS = ["USUBJID", "AGE", "SEX"]
RENAME = {"AGE": "AGEBL"}
want = have[KEEP_VARS].rename(columns=RENAME)
# 删除若干列
df = df.drop(columns=["TEMP", "X1"])
# 按模式删除
df = df.drop(columns=[c for c in df.columns if c.startswith("_")])
# 长度语义:Python 字符串没有固定长度
# 要补空格 → .str.pad(n);要去尾空格 → .str.rstrip()
行数(NOBS / _N_)
len(df) # nobs
df.shape[0] # nobs
df.index # 行号(对应 _N_-1)
df["rowno"] = range(1, len(df) + 1) # 显式加 _N_
8.5 纵向堆叠与横向连接(SET / MERGE)
SET = pd.concat()
data all;
set ae_ds ae_drug; /* 纵向堆叠,按列名对齐 */
run;
# axis=0 表示纵向堆叠;ignore_index 重新编号(否则行号会重复)
all_df = pd.concat([ae_ds, ae_drug], axis=0, ignore_index=True)
# 保留来源标记(对应 SAS 的 in= 选项)—— QC 常用
all_df = pd.concat(
[ae_ds.assign(_SOURCE="ae"), ae_drug.assign(_SOURCE="drug")],
axis=0, ignore_index=True,
)
MERGE = pd.merge()
/* SAS:必须先排序 */
proc sort data=adsl; by USUBJID; run;
proc sort data=adex; by USUBJID; run;
data adam;
merge adsl (in=a) adex;
by USUBJID;
if a; /* 左连接:只保留 adsl 中的受试者 */
run;
# pandas:不需要预排序
adam = pd.merge(adsl, adex, on="USUBJID", how="left")
# 保留来源标记(对应 in=a / in=b)
adam = pd.merge(adsl, adex, on="USUBJID", how="left", indicator=True)
adam["_merge"].value_counts()
# both 300
# left_only 6 ← 这些是 adsl 有、adex 没有的(QC 关注点)
连接类型对照(重要!)
| SAS 写法 | pandas how= |
语义 |
|---|---|---|
merge a(in=a) b; if a; |
how="left" |
保留 A 全部 |
merge a b(in=b); if b; |
how="right" |
保留 B 全部 |
merge a(in=a) b(in=b); if a and b; |
how="inner" |
只保留匹配上的 |
merge a(in=a) b(in=b); if a or b; |
how="outer" |
全部保留 |
# 连接键名称不同时(对应 SAS 的 rename=)
merged = pd.merge(a, b, left_on="USUBJID", right_on="SUBJID", how="left")
# 多键连接(对应 by USUBJID VISITNUM)
merged = pd.merge(a, b, on=["USUBJID", "VISITNUM"], how="left")
# 避免列名冲突(对应 SAS 的变量覆盖警告)
merged = pd.merge(a, b, on="USUBJID", suffixes=("_ADSL", "_ADEX"), how="left")
🔥 必须校验"一对多"关系。SAS 在 M:1 时会静默覆盖, pandas 会直接报错(这其实是好事):
MergeError: Merge keys are not unique in right dataset出现这个错时,先确认是不是真的需要how="left"+ 多对多, 通常意味着你的连接键选错了(漏了 VISITNUM / PARAMCD 之类的维度)。 这个报错往往帮你发现了真实的逻辑 bug。
如果确实需要"多对多 + 笛卡尔积"(SAS 的行为)
merged = pd.merge(a, b, on="USUBJID", how="cross") # 或 how="inner" 但先 validate=None
# 显式声明允许的关系(推荐,相当于写文档)
merged = pd.merge(a, b, on="USUBJID", how="inner",
validate="many_to_many")
8.6 排序与去重(PROC SORT)
proc sort data=ae out=ae2 nodupkey; by USUBJID AEDECOD; run;
# 排序
ae2 = ae.sort_values(["USUBJID", "AEDECOD"])
# 混合升降序(对应 by USUBJID descending AEDECOD)
ae2 = ae.sort_values(["USUBJID", "AEDECOD"], ascending=[True, False])
# 去重(保留第一条)—— 对应 nodupkey
ae2 = ae.drop_duplicates(subset=["USUBJID", "AEDECOD"], keep="first")
# 保留最后一条(对应 SAS 的 if last.xxx)
ae2 = ae.drop_duplicates(subset=["USUBJID", "AEDECOD"], keep="last")
# 找出重复记录(QC 用)
dups = ae[ae.duplicated(subset=["USUBJID", "AEDECOD"], keep=False)]
# 保留每组某列最大/最小的那条(SAS 里要 sort + first.,pandas 一行)
latest = ae.sort_values("VISITNUM").drop_duplicates("USUBJID", keep="last")
💡
drop_duplicates的行为受排序影响。 SAS 的nodupkey也是"排序后保留第一条"。 所以:先sort_values再drop_duplicates,语义才明确、可复现。
8.7 分组汇总(PROC MEANS / SUMMARY)
proc means data=adsl n mean std min max median maxdec=2;
class TRT01P AGEGR1;
var AGE;
run;
# 基础版
adsl.groupby(["TRT01P", "AGEGR1"])["AGE"].agg(["count", "mean", "std", "min", "max", "median"])
# 自定义聚合(推荐:用命名聚合,输出列名可控,便于后续报表)
summary = adsl.groupby("TRT01P")["AGE"].agg(
N="count",
Mean="mean",
SD="std",
Min="min",
Max="max",
Median="median",
)
# 多变量 + 多统计量
summary = adsl.groupby("TRT01P").agg(
N=("USUBJID", "nunique"),
AGE_MEAN=("AGE", "mean"),
AGE_SD=("AGE", "std"),
BMI_MEAN=("BMI", "mean"),
)
# SAS 的 n / nmiss 对照
adsl.groupby("TRT01P")["AGE"].agg(
N="count", # 非缺失个数
NMISS=lambda s: s.isna().sum(),
)
SAS CLASS vs pandas groupby 的关键差异
SAS CLASS |
pandas groupby |
|
|---|---|---|
| 缺失值分组 | 默认单独成组(显示为 .) |
默认排除(dropna=True) |
| 组内缺失值 | 默认排除(除非加 missing 选项) |
默认排除(skipna=True) |
| 保留分组列 | 是 | as_index=False 或 reset_index() |
| 不存在的组合 | CLASSDATA / COMPLETETYPES |
reindex |
# 让缺失值单独成组(对应 SAS 的 class 显示 .)
df.groupby("TRT01P", dropna=False)["AGE"].mean()
# 输出成"扁平"表格(不把分组键变索引)—— 报表场景几乎都要
summary = adsl.groupby("TRT01P", as_index=False)["AGE"].mean()
# 让所有组合都出现(补 0)——对应 SAS 的 COMPLETETYPES
pivot = df.pivot_table(index="AGEGR1", columns="TRT01P", values="USUBJID",
aggfunc="count", fill_value=0)
PROC RANK → rank()
adsl["AGE_RANK"] = adsl["AGE"].rank(ascending=False, method="min")
adsl["AGE_RANK_GRP"] = adsl["AGE"].rank(pct=True) # 百分位
adsl["AGE_QUARTILE"] = pd.qcut(adsl["AGE"], 4, labels=["Q1","Q2","Q3","Q4"])
adsl["GRP_RANK"] = adsl.groupby("TRT01P")["AGE"].rank() # 组内排名
8.8 频数统计(PROC FREQ)
proc freq data=adsl;
tables TRT01P * AGEGR1 / nocol norow nopercent missing;
run;
# 单变量频数
adsl["TRT01P"].value_counts(dropna=False)
adsl["TRT01P"].value_counts(normalize=True).round(4) # 占比
# 交叉表(对应 tables A * B)
pd.crosstab(adsl["TRT01P"], adsl["AGEGR1"])
# 含缺失值 + 加合计
pd.crosstab(adsl["TRT01P"], adsl["AGEGR1"], dropna=False, margins=True)
# 长格式输出(更适合后续拼装报表)
freq = (adsl.groupby(["TRT01P", "AGEGR1"], dropna=False)
.size().reset_index(name="N"))
💡
margins=True就是 SAS 的tables A*B / ... expected里没有的那个合计行/列。 做 CSR 报表时非常常用。
8.9 PROC SQL → pandas
SAS 的 PROC SQL 和 pandas 的对应关系比较直接:
proc sql;
create table want as
select USUBJID,
TRT01P,
AGE,
case when AGE >= 65 then 'Y' else 'N' end as ELDERLY,
count(distinct AEDECOD) as N_AE
from adsl as a
left join adae as b
on a.USUBJID = b.USUBJID
where a.SAFFL = 'Y'
group by USUBJID, TRT01P, AGE
having count(distinct AEDECOD) > 0
order by TRT01P, AGE desc;
quit;
# SQL 风格的等长链式写法(pandas 推荐)
want = (
pd.merge(adsl, adae, on="USUBJID", how="left")
.query("SAFFL == 'Y'")
.assign(ELDERLY=lambda d: np.where(d["AGE"] >= 65, "Y", "N"))
.groupby(["USUBJID", "TRT01P", "AGE"], as_index=False)
.agg(N_AE=("AEDECOD", "nunique"))
.query("N_AE > 0")
.sort_values(["TRT01P", "AGE"], ascending=[True, False])
[["USUBJID", "TRT01P", "AGE", "ELDERLY", "N_AE"]]
)
逐条对照:
| SQL 子句 | pandas |
|---|---|
select 列 |
df[[...]] 或 .agg() |
where |
.query() |
case when |
.assign(x=lambda d: np.where(...)) |
group by |
.groupby() |
having |
.query()(在 groupby 之后) |
order by |
.sort_values() |
left join |
pd.merge(..., how="left") |
distinct |
.drop_duplicates() |
count(distinct x) |
.agg(x="nunique") |
limit 10 |
.head(10) |
💬 另一个选择:直接写 SQL。如果你(或 QC 同事)觉得 SQL 更顺手, pandas 里可以接 DuckDB 直接在 DataFrame 上跑 SQL:
python import duckdb duckdb.sql(""" SELECT TRT01P, COUNT(*) AS N, AVG(AGE) AS MEAN_AGE FROM adsl WHERE SAFFL = 'Y' GROUP BY TRT01P """).df()这在"从 SAS 迁移"的过渡期非常好用——先用 SQL 把逻辑跑对, 再逐步改写成 pandas。第 17 章会展开。
chain 式的可读性技巧:分步 vs 链式
# 分步写(更好调试,推荐给临床程序——每一步可 print 核对)
step1 = pd.merge(adsl, adae, on="USUBJID", how="left")
step2 = step1.query("SAFFL == 'Y'")
step3 = step2.groupby("TRT01P", as_index=False).agg(N=("USUBJID", "nunique"))
# 链式写(更紧凑,但出错时难定位)
result = (pd.merge(adsl, adae, on="USUBJID", how="left")
.query("SAFFL == 'Y'")
.groupby("TRT01P", as_index=False)
.agg(N=("USUBJID", "nunique")))
🧠 临床编程建议:优先分步写。 因为临床程序的核心要求是可核对(QC-able)—— 每一步的中间结果都能取出来看,QC 才能逐环节比对。 链式写法适合你已完全掌握、且逻辑很短的场景。
8.10 组内处理:FIRST. / LAST. / RETAIN / LAG
这是 SAS 里必须"靠排序 + 自动变量"才能做的事,pandas 有更直接的表达。
FIRST / LAST
proc sort data=lb; by USUBJID VISITNUM; run;
data lb2; set lb;
by USUBJID;
if first.USUBJID then FLAG = 'Y'; /* 每个受试者的第一条 */
if last.USUBJID then ENDF = 'Y'; /* 每个受试者的最后一条 */
run;
lb2 = lb.sort_values(["USUBJID", "VISITNUM"])
# 方法 1:cumcount(推荐,直观)
lb2["_rn"] = lb2.groupby("USUBJID").cumcount() # 组内序号,0 开始
lb2["FLAG"] = lb2["_rn"] == 0 # 第一条
n_per_subj = lb2.groupby("USUBJID")["USUBJID"].transform("size")
lb2["ENDF"] = lb2["_rn"] == n_per_subj - 1 # 最后一条
# 方法 2:保留每组第一条/最后一条记录(更常用)
first_rec = lb2.drop_duplicates("USUBJID", keep="first")
last_rec = lb2.drop_duplicates("USUBJID", keep="last")
RETAIN(保持上一行的值)
data lb2; set lb;
retain BASE;
if FIRST.VISITNUM then BASE = AVAL; /* 首次访视作为基线 */
run;
# 取每组第一个值并广播回所有行(不是循环!)
lb["BASE"] = lb.groupby("USUBJID")["AVAL"].transform("first")
# 用基线访视的值(更严谨:按 ABLFL='Y' 标记)
base = lb.loc[lb["ABLFL"] == "Y", ["USUBJID", "AVAL"]].rename(columns={"AVAL": "BASE"})
lb = pd.merge(lb, base, on="USUBJID", how="left")
# 向下填充(真正的 retain 语义:用上一个非缺失值)
df["LASTFILLED"] = df["VALUE"].ffill()
# 组内向下填充
df["LASTFILLED"] = df.groupby("USUBJID")["VALUE"].ffill()
# 组内累计(对应 retain + sum 语句)
df["CUMSUM"] = df.groupby("USUBJID")["DOSE"].cumsum()
df["CUMMAX"] = df.groupby("USUBJID")["AVAL"].cummax()
LAG / DIF
data lb2; set lb;
prev = lag(AVAL);
chg = dif(AVAL);
run;
df["PREV"] = df["AVAL"].shift(1) # lag(AVAL)
df["CHG"] = df["AVAL"].diff() # dif(AVAL)
df["CHG2"] = df["AVAL"] - df["AVAL"].shift(2) # lag2
# ⚠️ 关键差异:pandas 的 shift 不考虑 BY 组!
# 组内 shift 必须先排序,再用 groupby().shift()
df = df.sort_values(["USUBJID", "VISITNUM"])
df["PREV"] = df.groupby("USUBJID")["AVAL"].shift(1)
🔥 这是最容易出错的地方:SAS 的
lag()在 DATA 步里天然按当前 BY 组的顺序作用;pandas 的.shift()是整列位移, 不分组会在受试者交界处"串行"(把上一个受试者的最后一条当成本受试者的前一条)。 必须groupby().shift()。
8.11 自定义格式映射(PROC FORMAT)
proc format;
value $sexf
'F' = 'Female'
'M' = 'Male'
other = 'Unknown';
value agegrp
low -< 65 = '<65'
65 - 80 = '65-80'
80 <- high= '>80';
run;
# 离散映射 → dict + map(对应 value $sexf)
SEX_FMT = {"F": "Female", "M": "Male"}
df["SEX_DECODE"] = df["SEX"].map(SEX_FMT).fillna("Unknown")
# 或用 replace(保留未匹配的原值)
df["SEX_DECODE"] = df["SEX"].replace(SEX_FMT)
# 区间映射 → pd.cut(对应 value agegrp)
df["AGEGR1"] = pd.cut(df["AGE"], bins=[-np.inf, 65, 80, np.inf],
labels=["<65", "65-80", ">80"], right=False)
# 保留为"有顺序的分类"(对应 SAS format 的排序输出顺序)
TRT_ORDER = ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
df["TRT01P"] = pd.Categorical(df["TRT01P"], categories=TRT_ORDER, ordered=True)
df.groupby("TRT01P", observed=False).size() # 输出顺序 = TRT_ORDER
🔥
pd.Categorical是复刻 SAS 输出的关键: 临床报表要求治疗组按固定顺序(而不是字母序)输出, SAS 靠CLASS的顺序或 format;pandas 靠Categorical的categories顺序。 这是报表现场最常见的"顺序不对"问题的根因。注意:pandas 2.x 起
groupby对 Categorical 需要observed=False才能显示所有类别(即使计数为 0)。这在做"空治疗组也要出现在表里"时很重要。
8.12 综合实战:一段完整 SAS 程序的重写
原始 SAS(典型的安全性人口学汇总):
proc sort data=adsl; by USUBJID; run;
proc sort data=adae; by USUBJID; run;
data want;
merge adsl (in=a) adae (in=b);
by USUBJID;
if a;
if AGE < 65 then AGEGR1 = '<65';
else if AGE <= 80 then AGEGR1 = '65-80';
else AGEGR1 = '>80';
if not missing(AEDECOD) then HAS_AE = 'Y';
else HAS_AE = 'N';
run;
proc means data=want n mean std maxdec=1;
class TRT01P AGEGR1;
var AGE;
run;
pandas 重写:
import numpy as np
import pandas as pd
# 1) 连接(不需要预先排序)
want = pd.merge(adsl, adae[["USUBJID", "AEDECOD"]], on="USUBJID", how="left")
# 2) 派生 AGEGR1
want["AGEGR1"] = np.select(
[want["AGE"] < 65, want["AGE"] <= 80], ["<65", "65-80"], default=">80"
)
# 3) 派生 HAS_AE(注意 pandas 里字符缺失是 NaN,不是 '')
want["HAS_AE"] = np.where(want["AEDECOD"].notna(), "Y", "N")
# 4) 分组汇总
summary = (want.groupby(["TRT01P", "AGEGR1"], as_index=False)
.agg(N=("AGE", "count"), Mean=("AGE", "mean"), SD=("AGE", "std"))
.round(1))
# 5) 核对
print(summary.to_string(index=False))
核对清单(迁移时必做):
- [ ] 行数是否一致?(连接类型选对了吗)
- [ ] 分组计数是否一致?(缺失值分组差异)
- [ ] 数值统计量是否一致?(舍入方式差异,见第 13 章)
- [ ] 输出顺序是否一致?(Categorical vs CLASS 顺序)
8.13 动手练习
-
筛选+派生:从
data/samples/adsl.csv中取出 SAFFL='Y' 且 AGE>=65 的受试者, 派生AGEGR1(用pd.cut),输出人数。 -
连接:把 ADSL 和 ADAE 左连接,统计每个受试者的不良事件条数 (提示:
groupby+size,注意没有 AE 的受试者应为 0 而不是缺失)。 -
去重:找出 ADAE 中每个受试者、每个
AEDECOD的首次发生记录 (按ASTDT排序后取第一条)。 -
组内处理:在 ADAE 中,用
groupby().shift()计算每个受试者 相邻两次不良事件的天数间隔。 -
完整重写:找一段你自己写过的 SAS 程序(不需要很长,20 行以内), 用 Python 重写,并用 8.12 节的核对清单逐项验证结果一致。
上一章 ← 第 07 章 · pandas 入门 下一章 → 第 09 章 · 合并、重塑与分组汇总