SAS基础入门全流程:数据步、清洗、函数与ODS报表交付

发布时间:2026/10/29 4:23:23
SAS基础入门全流程:数据步、清洗、函数与ODS报表交付

简介:《小白学SAS基础教程》是一份面向零基础读者的SAS入门读物,适合被命令语法劝退、希望从实际数据处理任务入手的学习者。全书采用对话体,以“小白”的提问推进,围绕数据集建立、常用函数、数据清洗、SAS绘图与制表展开,强调每个操作背后的实际用途,而非简单罗列命令。包内为1个PDF文件,压缩包约1.76MB,篇幅集中,便于在电脑或平板上按章节阅读并对照练习。书中涉及缺失值、异常值、重复值处理,以及常规图形绘制和规范报表输出,并给出可直接套用的处理思路与程序示例。已有709人学习,适合作为SAS入门的系统起点,也可供需要提升数据整理与报表效率的职场人士查阅。

1. 一份把 SAS 当“工具箱”而不是“命令手册”的讲义

多数人学 SAS 基础入门的路径是这样的:打开 Help,把 data、input、proc 逐条抄一遍,三天后连自己建的数据集到底存哪了都说不清。这份《小白学 SAS 基础教程》走的是另一条路:全书用师徒对话推进,主角是个零基础的新人,从编辑器窗口里写三句话开始,一路做到数据合并、去重、缺失值与异常值定位、函数派生变量、sgplot 出图、tabulate 制表,最后用 ODS 把结果导成能直接交上去的报表。配套给的是全书程序(标注了对应页码)、9 段视频,以及两份附录:一份面向新手,列常见错误提示和高效与低效写法对比;一份面向熟手,讲怎么在 SAS 里调用 R。适合刚入职被 LOG 窗口和“数据集去哪了”劝退的人,也适合要带新人、需要一套能照着讲的顺序的人。

2. 数据步与逻辑库:三句话建数据集,为什么关掉 SAS 就没了

这一章解决的是最基础也最容易埋雷的两件事:数据集怎么建出来、建完放在哪。很多人第一次用 SAS 时,程序跑通了、输出也看到了,第二天打开软件发现数据集消失,于是怀疑软件有问题。问题不在软件,在于从头到尾没人讲清楚 Work 这个逻辑库的定位。

2.1 data、input、cards 三句话与语句边界

SAS 的数据步有固定骨架,三句话加一个收尾,缺一句都跑不动。下面这段是最小可用版本,建议先原样敲一遍,看 LOG 窗口的输出再改。

/* 最小数据步:data 起名,input 声明变量,cards 引数据 */ data first; input gender age; cards; 1 30 0 24 ; run;

逻辑说明:data first;给数据集起名,这个名字遵循三条硬规则——只能由英文字母、数字和下划线组成,首字符不能是数字,长度不超过 32 个字符。input gender age;声明两个数值变量,SAS 默认以空格作为字段分隔符,读到空格就认为当前变量结束。cards;之后是数据区,数据全部写完必须另起一行写一个单独的分号,这一行分号是数据结束的标志,跟在前一条数据后面会被当成数据内容。run;告诉 SAS 这一步可以执行了。

参数怎么改:变量数量随意增减,上万列 SAS 也吃得下;如果某个变量是字符型,必须在变量名后加$,否则 SAS 按数值读,遇到字母就报Invalid numeric data;想让 SAS 接受中文变量名,可以加options validvarname=any;,但不建议在生产代码里这么干,迁移和排错都会变麻烦。数据集名和变量名之外还有一类东西叫“语句边界”,SAS 里分号就是句号,中文分号、全角分号一律不认,这一条踩过的人不在少数。

cards和datalines完全等价,前者是老写法,后者语义更清楚。选哪个纯属习惯,同一份代码里保持一致就行。

2.2 逻辑库与 libname:临时数据集和永久数据集的分水岭

SAS 资源管理器里默认能看到几个库,Work 是留给用户建数据集的,其余几个放的是软件自带的地图数据、帮助数据和练习数据。Work 的特点是一次性的:关掉 SAS,库里所有数据集清空。这就是“昨天还在、今天没了”的全部原因。

要留住数据,得先给一个物理目录挂上逻辑库名,这个动作由libname完成。

/* 把物理目录挂成逻辑库 mylib,之后的 mylib.xxx 就是永久数据集 */ libname mylib "/data/sasproj"; data mylib.class_clean; set sashelp.class; where age >= 12; run; /* 用完可以解除挂载,避免下次同名冲突 */ libname mylib clear;

逻辑说明:libname 库名 "路径";建立逻辑库与磁盘目录的映射,之后所有写成库名.数据集名的形式都会落到那个目录里,格式是 SAS 自己的 sas7bdat 文件。set sashelp.class;读取软件自带的示例数据,where在读取阶段就完成过滤。最后libname mylib clear;解除映射,这一步在切换项目目录时尤其重要,同一个会话里重复给 mylib 赋不同路径而不 clear,很容易读到旧目录的文件。

参数怎么改:路径用双引号包住,Windows 下写"D:\sasproj"也能识别;如果目录不存在,SAS 不会自动创建,LOG 里会给出明确提示,先去把目录建好再回来跑。多人协作时库名尽量带项目前缀,避免同一台机器上不同项目的库名撞车。

注意:work.raw和raw是同一个东西,SAS 默认库就是 Work。凡是没写库名的数据集,一律是临时的。

2.3 informat 与 format:读进来和显示出来是两件事

新手最容易混的一对概念。informat 管的是“这串字符按什么规则解析成值”,format 管的是“这个值显示成什么样子”。两者完全独立,一个日期变量可以读进来是数值、显示成 2024-03-15,也可以读进来就错、显示成一串数字。

/* informat 决定怎么读,format 决定怎么显示 */ data fmt_demo; informat dt yymmdd10. name $20. score 8.2; format dt yymmddd10. score 8.2; input dt name score; cards; 2024-03-15 A 88.5 ; run;

逻辑说明:informat dt yymmdd10.告诉 SAS 输入的2024-03-15是带横线的年月日,解析后存成 SAS 日期值(1960 年 1 月 1 日起的天数)。format dt yymmddd10.决定输出窗口里怎么显示,换成date9.就显示成15MAR2024,底层数值一个字节都没变。name $20.里的 20 是字符长度,这个长度是硬约束,超长部分直接截断且不报错,属于典型的静默失败。

两个特殊输入修饰符值得单独记:冒号:用于不定长字符,读到分隔符就停,避免为每个值预留固定长度;&用于值内部本身含空格的字符变量,需要配合infile指定分隔符。

/* 冒号读不定长,& 读含空格的字段,配合 dlm 指定逗号分隔 */ data addr; infile datalines dlm=","; input id : $8. name & $20.; datalines; 1001,A同学,某地址一号楼 ; run;

dlm=","把分隔符从空格改成逗号,这样地址里的空格就不会被误当成分隔符;&修饰符要求后面跟一个分隔符才能结束读取,所以必须配 dlm 使用,只写&不写 dlm,读出来的结果会让人怀疑人生。

2.4 从 Excel 到 SAS:proc import 的几个必设参数

实际工作里数据多半在 Excel 或 CSV 里,手工敲 cards 不现实。常见做法是用proc import一把导进来,但默认参数踩坑率很高。

/* 导入 Excel,重点在 guessingrows 和 getnames */ proc import datafile="/data/raw.xlsx" out=work.raw dbms=xlsx replace; getnames=yes; guessingrows=max; run;

逻辑说明:dbms=xlsx指定文件类型,out=指定落地数据集,replace表示同名数据集直接覆盖,不加这个选项第二次运行会报错。getnames=yes把首行当变量名,不加的话首行会变成一条观测,后面所有类型判断全乱。guessingrows=max让 SAS 扫描全部行来推断变量类型和字符长度,默认只扫前若干行,前几行都是短值、后面出现长文本时就会被截断,而且 LOG 里只有一句轻描淡写的提示。

参数怎么改:CSV 文件用dbms=csv,同时加encoding='utf-8'处理中文;如果源文件是 GBK 编码,改成encoding='gbk',编码不匹配的典型表现是中文变问号或变乱码,而数值列不受影响,很容易被误判成“文件坏了”。数据量特别大时proc import的性能不理想,稳妥做法是先另存为 CSV,再用infile加dsd和truncover手工读,控制力强得多。

/* 大数据量场景:手工读 CSV,比 proc import 可控 */ data work.raw; infile "/data/raw.csv" dsd firstobs=2 encoding="utf-8" truncover; input id name : $30. score; run;

dsd让 SAS 正确处理引号包裹的字段和连续分隔符,firstobs=2跳过表头,truncover允许最后一列长度不足时不报错。这三个选项组合起来,基本能覆盖绝大多数脏 CSV。

3. 数据清洗三段式:合并、去重、缺失与异常值定位

数据进到 SAS 只是开始,真正占时间的是清洗。这一章按“拼起来、去重复、找问题”三段推进,每一段都给可直接改参数复用的代码骨架。讲义里反复强调的一点是:清洗不是把报错消掉,而是让每一行的来路和去向都可追溯。

3.1 set 与 merge:先分清纵向拼和横向对齐

两个语句长得像,语义完全不同。set是纵向堆叠,行数相加;merge是横向对齐,按 by 变量把两张表的列拼到一起。搞混的后果是行数莫名其妙翻倍或者减半。

/* 纵向合并:结构一致的多张表摞起来,in= 记录来源 */ data work.all; set q1 q2 inds=src; source = src; /* 1 表示来自 q1,2 表示来自 q2 */ run;

inds=生成一个临时变量,记录当前观测来自 set 列表里的第几个数据集,这个变量只在数据步里有效,赋给source才留得下来。用它可以快速核对各表贡献了多少行,proc freq data=work.all; tables source; run;一跑就清楚。

/* 横向合并:先排序,再按 by 对齐 */ proc sort data=work.stu; by id; run; proc sort data=work.score; by id; run; data work.merged; merge work.stu(in=a) work.score(in=b); by id; if a; /* 只保留主表里存在的记录,左连接语义 */ if a and b then match_flag = 1; else match_flag = 0; run;

in=后面跟的变量名是临时的 0/1 标志,if a;就是只保留主表有的行。if a and b则能标出匹配情况,清洗阶段非常有用,能一眼看出有多少记录在副表里找不到对应。最常见的报错是BY variables are not properly sorted,原因就是 merge 前没排序,或者两张表排序方向不一致(一个升序一个降序)。

注意:merge 时若 by 变量在某张表里有重复值,结果会按笛卡尔方式展开,行数暴涨。合并前先确认 by 变量唯一,或者先用 3.2 的方法去重。

3.2 排序之后才有 first./last.:去重的两种取舍

去重的核心是proc sort的nodupkey和first./last.这两套写法,前者简单,后者可控。

/* 写法一:按主键去重,保留排序后的第一条 */ proc sort data=work.raw out=work.raw_dedup nodupkey; by id; run;

nodupkey按 by 变量组合去重,每组只留第一条。缺点是“第一条”由排序决定,你不指定排序规则,留下的就是物理顺序里的第一条,结果不可复现。

/* 写法二:显式控制保留哪一条,按更新时间倒序后取 first */ proc sort data=work.raw; by id descending updated; run; data work.raw_dedup; set work.raw; by id; if first.id; /* 每个 id 只保留排序后的第一条,即最新记录 */ run;

first.id和last.id是 SAS 自动生成的分组边界标志,前提是数据已按 id 排好序且数据步里有by id;。想保留最早一条就把descending去掉。这两个变量的本质是“当前观测是否为该 by 组的首/末条”,在组内做累计、做组内序号都靠它。

一个实用扩展是组内序号:if first.id then seq = 0; seq + 1;,这样每行都带上了自己在该 id 下的第几条,后续做“取最新两条”之类的需求直接筛seq <= 2就行。

3.3 数组加自动变量:一份查缺失值的通用骨架

缺失值检查如果一个个变量写if x = . then,几十列的表根本写不完。SAS 提供了两个自动变量列表_numeric_和_character_,配合数组和dim(),能写出一份对任何数据集都通用的骨架。

/* 通用缺失值骨架:逐行统计缺失个数 */ data work.miss_flag; set work.raw; array nums[*] _numeric_; /* 所有数值变量自动入组 */ array chars[*] _character_; /* 所有字符变量自动入组 */ miss_num = 0; miss_chr = 0; do i = 1 to dim(nums); miss_num + (nums[i] = .); /* 条件为真返回 1,累加即计数 */ end; do i = 1 to dim(chars); miss_chr + (chars[i] = ""); /* 字符缺失是空串,不是点 */ end; drop i; run; proc freq data=work.miss_flag; tables miss_num miss_chr; run;

逻辑说明:array nums[*] _numeric_;把数据集中所有数值变量一次性装进数组,不用手写变量清单,表结构变了代码也不用改。dim(nums)返回数组元素个数。miss_num + (条件)是 SUM 语句的写法,条件成立取 1、不成立取 0,逐行累加就得到该行的缺失变量个数。字符型缺失要判断空串"",写成.永远为假,这是新手最常犯的一个错误。

参数怎么改:如果只想检查某几列,把_numeric_换成显式列表array nums[*] score age income;;_temporary_数组可以用来存跨行累计值,写array cnt[10] _temporary_;。要注意 SUM 语句自带 retain 效果且把缺失当 0 处理,好处是不会因为某行缺失导致整列累计变空,代价是它会悄悄吞掉缺失信息,需要严格传播缺失时得换成x = x + y;的写法。

3.4 异常值定位:分位数筛选与 if / where 的边界

异常值没有统一定义,工程上常用分位数做初筛,把 1% 和 99% 之外的点先捞出来人工看。

/* 先算分位点,再把统计量带进数据步做筛选 */ proc means data=work.raw noprint; var score; output out=work.stat p1=p1 p99=p99 mean=mean std=std; run; data work.outlier; if _n_ = 1 then set work.stat; /* 只把单行统计量读进来一次 */ set work.raw; if score < p1 or score > p99; run;

if _n_ = 1 then set work.stat;是把汇总表的单行结果广播到每一行的标准写法,_n_是数据步的迭代计数器。这一句必须写在set work.raw;之前,顺序反了统计量只会在第一行有值。这种写法隐含 retain 效果,p1、p99 会一直保留到数据步结束。

if和where的区别值得单独说清:where在读取阶段过滤,不进入 PDV,效率高,但不能引用_n_和本步刚生成的新变量;if在 PDV 里执行,能引用所有变量,但整张表已经读进来了。大数据集上把能用where的条件都写成where,性能差距很明显。

4. 函数与派生变量:round 分箱、retain 累计、lag 取上一行

函数部分最忌讳按字母顺序背语法。讲义里给的思路是按“我要解决什么问题”去找函数,这里挑四组在真实清洗和报表里出现频率最高的展开。

4.1 数值、字符、日期、类型转换:四类函数的取用顺序

先把函数按用途分四类:数值计算、字符处理、日期时间、类型转换。实际写代码时的顺序通常是先转换类型,再处理字符,再算数值,最后处理日期。

data work.func_demo; set work.raw; /* 字符类:去空格再转大写,顺序不能反 */ name_up = upcase(strip(name)); name_len = length(strip(name)); /* 类型转换:put 数值转字符,input 字符转数值 */ score_c = put(score, 8.2); score_n = input(score_c, 8.); /* 日期类:datdif 算两个日期之间的天数 */ days = datdif(birth, today(), "act/act"); run;

strip()去掉首尾空格,upcase()转大写。如果先转大写再去空格,中间的空格仍然在,比较时照样不匹配。put(变量, 格式)把数值按指定格式转成字符,input(变量, 格式)反过来。这两个函数在做“字符型编号排序”时特别有用——字符串"10"会排在"9"前面,转成数值就正常了。datdif的第三个参数是计算基准,act/act表示按实际天数,做年龄和账龄时用它比手工减日期稳妥。

参数怎么改:put的格式要和目标长度匹配,put(score, 8.2)输出宽度 8、保留 2 位小数,宽度不够会变成科学计数法或者星号;input的格式要和源字符串结构匹配,源里带千分位逗号就得用commaw.。

4.2 round 不只是四舍五入:等距分箱为什么该用 floor

round(x, 10)的含义是“四舍五入到最接近的 10 的倍数”,不是“向下取整到 10 的倍数”。这两个语义在分箱场景下差别巨大。

data work.bin; set work.raw; bin_round = round(score, 10); /* 15 会变成 20,跨档 */ bin_floor = floor(score / 10) * 10; /* 15 落在 10,左闭右开 */ bin_label = put(floor(score / 10) * 10, best. -l) || "-" || put(floor(score / 10) * 10 + 9, best. -l); run;

score = 15时,round给 20,floor给 10。做等距分箱、做年龄分段、做金额区间统计,一律用floor(x/宽度)*宽度,保证区间是左闭右开、不重不漏。round的正确用途是控制小数位,比如round(x, 0.01)保留两位。

分箱标签用put(...) || "-" || put(...)拼出来,best. -l里的-l表示左对齐,拼出来的标签不会带着一堆空格。这一步做完,proc freq出来的表直接就是分档统计,不用再手工整理。

4.3 retain 与累加语句:组内累计和组内序号

retain的作用是让变量跨行保留值,默认情况下数据步每读一行所有变量都会重置。做累计、做序号、做“上一行带过来的状态”都靠它。

proc sort data=work.raw; by dept id; run; data work.cum; set work.raw; by dept id; retain dept_total 0; if first.dept then dept_total = 0; /* 每个部门开头清零 */ dept_total = dept_total + amount; /* 显式加法,缺失会传播 */ if first.dept then seq = 0; seq + 1; /* SUM 语句,自动 retain */ run;

retain dept_total 0;声明变量并在数据步开始时初始化为 0。if first.dept then dept_total = 0;保证每换一个部门重新开始累计。这里的dept_total = dept_total + amount;是显式加法,遇到缺失值结果就是缺失,语义严格。而seq + 1;是 SUM 语句,自动 retain 且把缺失当 0,永远不会产生缺失值。

这两种写法不是风格差异,是语义差异。需要“缺失就暴露出来”的场景用显式加法,需要“计数永远有值”的场景用 SUM 语句。混用的后果是累计列里偶尔冒出几个缺失,排查半天找不到源头。

4.4 dif 与 lag:队列语义和最常见的错位

lag(x)取的不是“上一行的 x”,而是“这条语句上一次被执行时的 x”。理解这句话,关于 lag 的绝大部分困惑就消失了。

/* 错误示范:lag 写在 if 里面,只有满足条件才入队 */ data work.wrong; set work.raw; if dept = "A" then prev = lag(score); /* 队列被条件打乱 */ run; /* 正确写法:先无条件取,再按条件使用 */ data work.right; set work.raw; prev = lag(score); if dept = "A" then diff = score - prev; run;

第一种写法里,只有 dept 为 A 的行才执行 lag,队列里存的都是 A 组的值,prev变成“上一个 A 的 score”,而不是上一行。第二种写法每行都入队,prev才是真正的上一行值,之后再决定用不用。

dif(x)等价于x - lag(x),同样受这个规则约束。做同比、环比、相邻记录差值时,另一条路是先proc sort再用retain手工保存上一行,逻辑更直白,出错时也更好定位。两条路都行,团队里统一一种即可,混着写最容易出问题。

5. 五个高频翻车点:从 LOG 报错到 ODS 样式丢失的排查顺序

清洗和派生写完,程序能跑通,不代表结果可信。这一章把实际使用中最常遇到的五处翻车按“现象、原因、解决”记下来,都是血泪经验换的。

5.1 数据步与导入环节的三处静默失败

第一处,字符变量忘了写$。现象:LOG 里出现NOTE: Invalid numeric data, name='A同学',那一行的字符变量变成缺失,数值变量也一起变缺失,但程序照常跑完,输出窗口看起来一切正常。原因:input语句里没加$的变量一律按数值读,SAS 尝试把文本转成数字失败,把整个 PDV 里该行的值置空。解决:input id name $;或者更稳妥地写input name : $20.;,同时养成看 LOG 里Invalid numeric data关键字的习惯,这类提示不会中断运行,只会静默污染数据。

第二处,proc import导入 Excel 后长文本被截断。现象:前几十行正常,后面某些行的备注列只剩半截,没有任何报错。原因:SAS 推断字符变量长度时默认只扫描前面若干行,后面出现更长的值就按已推断的长度截断。解决:加guessingrows=max;让它扫全表,数据量大时先另存为 CSV 再用infile手工读,长度在input里写死,可控性最好。

第三处,程序提交后既没输出也没报错,光标一直闪。现象:点了运行,LOG 空白,输出窗口空白,SAS 像卡住了。原因:cards;后面的数据区忘了写单独一行的分号,SAS 认为数据还没输完,一直在等。解决:翻到程序末尾补一个独立成行的;,然后重新提交。这个坑几乎每个新手都会踩一次,判断依据很简单——LOG 里连NOTE: The data set ... has ... observations都没出现,就说明数据步根本没结束。

5.2 合并、累计与输出环节的两处偏差

第四处,merge 之后行数暴涨。现象:两张表各一万行,merge 完变成三万行甚至更多,且找不到明显重复。原因:by 变量在其中一张表里不唯一,SAS 按笛卡尔方式把每个匹配组合都输出一行。解决:合并前先对两张表分别跑proc sort nodupkey或者用first./last.确认唯一性;合并后用if a and b标志位统计匹配率,匹配数明显超过主表行数就说明有重复。养成“merge 前后各看一次行数”的习惯,比事后查错省事得多。

第五处,ODS 输出到 Word 后表格样式全丢。现象:SAS 里看着是规整的三线表,导出 rtf 或 HTML 打开后边框粗细不一、表头没有加粗、列宽乱掉。原因:ODS 默认套用系统样式模板,没有显式指定 style,不同目标格式的默认样式不一致。解决:在ods rtf或ods html语句里加style=Journal,或者在proc template里定义自己的表模板再引用。

/* 导出 rtf 时显式指定样式,三线表效果才稳定 */ ods rtf file="/data/report.rtf" style=Journal; proc tabulate data=work.summary; class dept; var amount; table dept, amount*(n mean std); run; ods rtf close;

style=Journal是一套偏学术排版的样式,表头带底色、边框克制,导出后基本不用再手工调。ods rtf close;千万别漏,漏了文件会一直处于占用状态,内容不完整。

注意:任何 ODS 目标用完都要 close,包括 html、pdf、excel。多个目标同时打开时按打开的逆序关闭,顺序错了后面的文件会内容错乱。

6. 交付前的三段校验:把行数、缺失率、值域做成一次宏调用

清洗和制表跑完,我一般不会直接交。交之前固定跑三段校验:行数守恒、主键唯一、关键字段的值域和缺失率。这三件事写成宏,每次换数据集只改参数,比肉眼扫输出靠谱得多。

/* 交付前自检宏:行数、主键基数、数值变量缺失率 */ %macro check(ds=, key=); proc sql noprint; select count(*) into :nobs from &ds.; select count(distinct &key.) into :nkey from &ds.; select count(*) into :nmiss from &ds. where &key. is missing; quit; %put NOTE: 观测数=&nobs. 主键基数=&nkey. 主键缺失=&nmiss.; proc means data=&ds. nmiss n mean min max maxdec=2; var _numeric_; run; %mend check; %check(ds=work.final, key=id);

nobs和nkey相等说明主键唯一,不等就回到第 3 章去重。nmiss不为 0 说明主键有空值,通常意味着上游合并出了问题。proc means里的nmiss直接给出每个数值变量的缺失个数,min/max用来对值域做一次目视——金额出现负数、年龄出现 200,一眼就能看出来。

进阶一点的做法是把阈值也参数化,超过就自动报警,而不是靠人看 LOG:

/* 缺失率超阈值时在 LOG 里打醒目提示 */ %macro check2(ds=, var=, maxmiss=0.05); proc sql noprint; select sum(&var. is missing)/count(*) into :rate from &ds.; quit; %if &rate. > &maxmiss. %then %put ERROR: &var. 缺失率 &rate. 超过阈值 &maxmiss.; %else %put NOTE: &var. 缺失率 &rate. 在阈值内; %mend check2; %check2(ds=work.final, var=score, maxmiss=0.05);

sum(条件)/count(*)在 SQL 里直接算出缺失比例,%if做阈值判断,%put ERROR:打出来的行在 LOG 里是红色高亮的,扫一眼就能定位。阈值按业务定,问卷类数据 5% 已经算高,交易流水里金额字段缺失率超过 0.1% 就该回去查采集环节了。

从那以后我每次交报表前都强制走一遍这三段:行数对不对、主键唯一不唯一、关键字段的缺失和极值合不合理。这三步加起来不到一分钟,但拦下过的错误比任何一次代码评审都多。希望帮到你。

本文还有配套的精品资源,点击获取