什么是PROC FCMP

FCMP全称是Function compiler,可以自定义函数和函数里的子程序,可以简单理解成R里面的fuction,Python里的def,能够写一些自定义函数,在其他步骤中像内置函数比如SUM()MEANS()SUBSTR()一样调用。

这个PROC语句,可能是大多数人在工作中都很少用到的内容,因为它和Macro功能很像但略有不同,本篇文章是对PROC FCMP的学习笔记。

基础用法示例

主要分三步:

  1. 用proc fcmp定义函数;

  2. 用options cmplib指定函数保存位置;

  3. 在程序里调用。

/* 1. 写cal_bmi函数 */
proc fcmp outlib=work.myfuncs.math;
    function cal_bmi(weight_lb, height_in);
        weight_kg = weight_lb * 0.453592;
        height_m  = height_in * 0.0254;

        if height_m > 0 then 
            bmi = weight_kg / (height_m ** 2);
        else
            bmi = .;
        
        return(bmi);
    endsub;
run;

/* 2. options 指定函数位置 */
options cmplib=(work.myfuncs);

/* 3. 在data步中调用 */
data class;
    set sashelp.class;
    bmi = cal_bmi(weight, height);
run;

function部分属于是主函数定义,return只能填写函数返回值;一看便知,这function基本上和其他编程语言里的自定义函数一个意思。但是除了指返回一个值的function,FCMP里还可以用subroutine定义多个子程序,用outargs定义多个返回值,增强了功能性,示例为输入in,返回inv,inm两个变量:

/* 定义subroutine子函数 */
proc fcmp outlib=work.funcs.temp;
subroutine inverse(in,inv,inm) group="generic";
   outargs inv, inm;
   if in=0 then do;
    inv=.;
    inm=.;
   end;
   else do; 
    inv=1/in;
    inm = inv + 1;
   end;
endsub;
run;

options cmplib=work.funcs;

/* 在data步用call 子函数名调用 */
data a;
   x=5;
   call inverse(x, y, z);
   put x= y= z=;
run;

x=5 y=0.2 z=1.2

PROC FCMP的意义

从上面的示例,可以看出PROC FCMP的意义在于将算法模块化,之后能够重复调用,这样封装复杂逻辑就能起到:

  • 让主代码结构保持简洁、优雅;

  • 简化代码维护,如果后续计算规则改变,就只需要修改FCMP里的函数定义;

  • 跨过程步调用,除了DATA步之外,还可以在许多PROC步中调用,让程序更加灵活。

与Macro相比的区别

虽然Macro也有模块化、重复调用的能力,但两者还是有一些区别:

最直接的区别:

Macro是代码生成器,目的是把提前编译好的Macro里的代码文本写在程序里运行,不管里面用了多少宏变量、DATA/PROC步或者只是单行代码,都只是运行预编写的程序而已,这就是为什么用MPRINT能打印Macro的语句;

而FCMP是直接封装了逻辑成为一个内置函数,用MPRINT是无法打印内容的。

什么时候用Macro

  • 需要把一串包含DATA步、PROC步的完整流程封装起来,用Macro;

  • 需要动态生成变量名,如tmp1、tmp2、tmp3...用Macro;

  • 需要执行条件判断、循环执行或者指定的单行/多行代码;

什么时候用FCMP

  • 需要用复杂计算公式计算变量、并且重复调用时;

  • 需要简化DATA步代码,避免过多IF-THEN-ELSE时;

  • 需要和一些PROC语句如REPORT、FORMAT连用时;

  • 运行几万行甚至更多数据,用Macro处理速度比FCMP慢时(解释性Macro语句运行会比编译好的FCMP慢)。

PROC FCMP的应用场景

作为处理临床试验数据的sp,工作内容中任何【行内复杂逻辑计算且需要在不同数据集或项目中复用】的地方,都可以使用FCMP处理数据、减少代码量、降低Validation风险。比如:

  • ADaM编程中复杂的日期处理和填补;

  • LB里衍生CTCAE;

  • eGFR肾小球滤过率的医学公式计算,涉及到性别、种族、年龄以及肌酐值等;

  • 需要把FCMP写成的函数,像内置函数一样用在一些PROC语句里的时候。

实操

官方文档里PROC FCMP里的高级应用其实和实际工作有点远、要么就是能用Macro替代导致学习成本略高。实操这里举三个例子,一个是官方示例,计算study day

计算Study day

proc fcmp outlib=sasuser.funcs.trial;
   function study_day(intervention_date, event_date);
      n=event_date - intervention_date;
         if n >= 0 then
            n=n + 1;
         return(n);
   endsub;
options cmplib=sasuser.funcs;
data _null_;
   start='15Feb2010'd;
   today='27Mar2010'd;
   sd=study_day(start, today);
   put sd=;
run;

虽然工作中普遍在Macro里计算study day,但是这个案例可以更好地帮助理解PROC FCMP的运作机制。

完成日期填补

用宏的方式写,之后可以改成FCMP,在做ADaM时就可以反复调用,让主程序清爽一点。这个操作可以用Macro也可以用FCMP,只要有自己一套固定的代码流程能够应对大部分工作情况、遇到不一样的情况能修改适配即可。

proc delete data=work._all_;run;quit;

proc fcmp outlib=work.funcs.adam_date;
    /* impute astdt */
    subroutine impute_astdt(stdtc $, trtsdt, astdt, astdtf $);
        outargs astdt, astdtf; /* return this two variable */

        astdt = .;
        astdtf = "";

        /* 1. Complete date (YYYY-MM-DD) */
        if length(strip(stdtc)) >= 10 then do;
            astdt = input(substr(stdtc,1,10), yymmdd10.);
            astdtf = ""; 
        end;

        /* 2. Missing day (YYYY-MM) */
        else if length(strip(stdtc)) = 7 then do;
            dt_year = input(substr(stdtc,1,4), best.);
            dt_month = input(substr(stdtc,6,2), best.);

            if not missing(trtsdt) and dt_year = year(trtsdt) and dt_month = month(trtsdt) then do;
                astdt = trtsdt;
            end;
            else do;
                astdt = input(strip(stdtc) || "-01", yymmdd10.);
            end;
            astdtf = "D";
        end;

        /* 3. Missing month and day (YYYY) */
        else if length(strip(stdtc)) = 4 then do;
            dt_year = input(substr(stdtc,1,4), best.);
            if not missing(trtsdt) and dt_year = year(trtsdt) then do;
                astdt = trtsdt;
            end;
            else do;
                astdt = input(strip(stdtc) || "-01-01", yymmdd10.);
            end;
            astdtf = "M";
        end;
    endsub;

    /* impute aendt */
    subroutine impute_aendt(endtc $, dthdt, aendt, aendtf $);
        outargs aendt, aendtf; /* return this two variable */
        aendt = .;
        aendtf = "";
        aendt_temp = .;

        /* 1. Complete date */
        if length(strip(endtc)) >= 10 then do;
            aendt_temp = input(substr(endtc,1,10), yymmdd10.);
            aendtf = "";
        end;

        /* 2. Missing day */
        else if length(strip(endtc)) = 7 then do;
            aendt_temp = intnx('month', input(strip(endtc) || "-01", yymmdd10.), 0, 'e');
            aendtf = "D";
        end;

        /* 3. Missing month and day */
        else if length(strip(endtc)) = 4 then do;
            aendt_temp = input(strip(endtc) || "-12-31", yymmdd10.);
            aendtf = "M";
        end;

        /* 4. select min date in aendt_temp, dthdt */
        if not missing(aendt_temp) then do;
            aendt = min(aendt_temp, dthdt);
        end;
    endsub;

run;

options cmplib=work.funcs;


/* 生成adae的主程序 */
data adae1;
    length astdt aendt trtsdt dthdt 8. astdtf aendtf $1.;

    if _N_ = 1 then do;
        dcl hash mergeadsl(dataset:'adam.adsl');
        mergeadsl.definekey('usubjid');
        mergeadsl.definedata('trtsdt', 'dthdt');
        mergeadsl.definedone();
        
        call missing(of trtsdt dthdt astdt aendt astdtf aendtf);
    end;

    set sdtm.ae;
    rc = mergeadsl.find();

    call impute_astdt(aestdtc, trtsdt, astdt, astdtf);
    call impute_aendt(aeendtc, dthdt, aendt, aendtf);

    format astdt aendt yymmdd10.;
run;

衍生CTCAE

这可能是SP工作中比较合适用PROC FCMP来做的内容,因为即使是写成Macro,这部分的运行也会造成巨量的文本解析,但是如果用FCMP就能更少文本解析、更快运行速度、更清晰代码结构,工作中根据SAP修改FCMP里面的内容即可,用function输出grade或者用subroutine可以输出更多参数,根据自己的习惯来就好。

proc fcmp outlib=work.funcs.ctcae;
    subroutine calc_ctcae(lbtestcd $, val, uln, lbtoxgr $, lbtoxdir $, lbtox $);
        /* 输出下面三个参数 */
        outargs lbtoxgr, lbtoxdir, lbtox;
        
        /* 前置条件 */
        if n(val, uln) < 2 or uln <= 0 then return;
        
        /* 开始计算CTCAE */
        if lbtestcd = 'ALT' then do;
            r = val / uln; /* 只计算一次倍数,减少重复运算量 */
            
            /* 布尔逻辑输出grade */
            g_num = 4 * (r > 20) + 
                    3 * (5 < r <= 20) + 
                    2 * (3 < r <= 5) + 
                    1 * (1 < r <= 3) + 
                    0 * (r <= 1)
                    ;
            lbtoxgr = put(g_num, 1.);
            if g_num > 0 then do;
                lbtoxdir = 'INC';
                lbtox = 'Alanine aminotransferase increased';
            end;
            else do;
                call missing(lbtoxdir, lbtox);
            end;
        end;
    
    endsub;
run;

options cmplib=work.funcs;

/* 主程序调用 */
data lb1;
    length lbtoxgr $6 lbtoxdir $3 lbtox $200;
    set sdtm.lb(keep=usubjid subjid lbtestcd lbstresn lbstnrhi);    
    call missing(lbtoxgr, lbtoxdir, lbtox);
    call calc_ctcae(lbtestcd, lbstresn, lbstnrhi, lbtoxgr, lbtoxdir, lbtox);
run;