ตระกูล IPW: แนวคิดเดียว กับคนที่หายไปสามแบบ

On this page
Read the English version
บทคัดย่อ
การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็น (inverse probability weighting) เติมช่องว่างสามแบบในข้อมูลทางคลินิกด้วยวิธีเดียว ผู้ป่วยที่ถูกสังเกตแต่ละคนถูกถ่วงน้ำหนักให้ทำหน้าที่แทนผู้ที่คล้ายกันซึ่งไม่ถูกสังเกต เมื่อกำหนดตัวแปรร่วม (covariates) น้ำหนักการรักษาแทนกลุ่มการรักษาที่ผู้ป่วยไม่ได้รับ น้ำหนักการเซ็นเซอร์ (censoring) แทนผู้ที่สูญหายจากการติดตาม และน้ำหนักการคัดเลือก (selection) แทนผู้ที่การศึกษาไม่เคยรับเข้า ในทะเบียนผู้ป่วยกระดูกสะโพกหักจำลอง น้ำหนักการเซ็นเซอร์ทำให้ผลต่างความเสี่ยง (risk difference) ของการเสียชีวิตภายในหนึ่งปีเปลี่ยนจาก -0.181 เมื่อตัดผู้ที่สูญหายออก เป็น -0.174 เทียบกับ -0.175 หากไม่มีใครสูญหาย มีเพียงน้ำหนักการรักษาร่วมกับน้ำหนักการเซ็นเซอร์ คือ -0.035 ที่ใกล้ค่าเชิงสาเหตุ -0.038 ในการทดลองที่ซ้อนอยู่ในทะเบียน น้ำหนักการคัดเลือกปรับผลภาวะสับสนเฉียบพลัน (delirium) ไปสู่ทะเบียนทั้งหมดหรือผู้ที่ไม่ได้เข้าร่วม โดยค่าประมาณแทบไม่เปลี่ยน แต่ค่าคลาดเคลื่อนมาตรฐานเพิ่มขึ้นมากกว่าสี่เท่า บทความนี้สรุปว่า น้ำหนักแต่ละชนิดต้องมีแบบจำลอง ข้อสมมติ และประชากรเป้าหมายที่ระบุชื่อของตัวเอง และน้ำหนักการคัดเลือกถ่ายโอนผลได้เฉพาะผ่านตัวปรับผล (effect modifier คือลักษณะที่เปลี่ยนขนาดของผล) ที่อยู่ในแบบจำลองของมัน
การทดลองที่ซ้อนอยู่ในทะเบียน และคนที่ไม่มีใครสังเกตเห็น
ทะเบียนผู้ป่วยกระดูกสะโพกหักระดับชาติแห่งหนึ่งเก็บข้อมูลผู้สูงอายุ 20,000 คน ทั้งหมดเป็นข้อมูลจำลอง ทะเบียนนี้บันทึกการผ่าตัดภายใน 24 ชั่วโมงหลังรับเข้าโรงพยาบาล (การผ่าตัดเร็ว) ภาวะสับสนเฉียบพลัน (delirium) หลังผ่าตัด และการเสียชีวิตภายในหนึ่งปี ภายในทะเบียนมีการทดลองแบบสุ่มเปรียบเทียบการผ่าตัดเร็วอยู่ด้วย ผู้ป่วย 950 คน จัดสรรอัตราส่วน 1:1 ให้ผ่าตัดเร็วหรือผ่าตัดช้ากว่า
การทดลองรับผู้ป่วยอายุน้อยที่ไม่มีภาวะสมองเสื่อมเป็นส่วนใหญ่ ทั่วทั้งทะเบียน ผู้ป่วยที่มีภาวะสมองเสื่อมสูญหายจากการติดตามก่อนครบหนึ่งปี 28.5% เทียบกับ 10.3% ในผู้ที่ไม่มีภาวะนี้ คณะกรรมการอำนวยการถามว่าผลของการทดลองใช้กับทั้งทะเบียนได้หรือไม่ และการสูญหายทำให้การเปรียบเทียบการเสียชีวิตภายในหนึ่งปีของทะเบียนเอนเอียงหรือไม่
เบื้องหลังทั้งสองคำถามมีคนที่ไม่มีใครสังเกตเห็นอยู่ พวกเขาคือผู้ป่วยแต่ละคนภายใต้การผ่าตัดอีกแบบหนึ่ง ผู้ป่วยที่สูญหายก่อนครบหนึ่งปี และผู้ป่วยในทะเบียนที่ไม่เคยเข้าร่วมการทดลอง แนวคิดการถ่วงน้ำหนักแนวคิดเดียวใช้แทนทั้งสามกลุ่มได้ หากการใช้แต่ละแบบมีแบบจำลอง ข้อสมมติ และเป้าหมายที่ระบุชื่อของตัวเอง
แนวคิดเดียว: ถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นที่จะถูกสังเกต
การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็น (inverse probability weighting, IPW) วิเคราะห์ผู้ที่ถูกสังเกต โดยถ่วงน้ำหนักแต่ละคนให้ทำหน้าที่แทนผู้ที่คล้ายกันซึ่งไม่ถูกสังเกต ในรูปแบบพื้นฐาน น้ำหนักคือหนึ่งส่วนความน่าจะเป็นที่จะถูกสังเกตในสถานะของตนเอง เมื่อกำหนดตัวแปรร่วม $X$ (ลักษณะที่วัดไว้ที่จุดเริ่มต้น เช่น อายุและภาวะสมองเสื่อม):
$$w_i = \frac{1}{P(i \text{ observed in own state} \mid X_i)}$$ในสมการนี้ $w_i$ คือน้ำหนักของบุคคล $i$ ผู้ที่มีโอกาสน้อยที่จะถูกสังเกตในสถานะของตนจะได้น้ำหนักมาก เพราะมีคนคล้ายพวกเขาที่ถูกสังเกตอยู่น้อย หลักการเดียวกันอยู่เบื้องหลังการถ่วงน้ำหนักสำหรับข้อมูลที่หายไปโดยทั่วไป [1]
รูปแบบนี้มีเป้าหมายเป็นทุกคน ทั้งผู้ที่ถูกสังเกตและผู้ที่ไม่ถูกสังเกต เมื่อเป้าหมายคือเฉพาะผู้ที่ไม่ถูกสังเกต น้ำหนักจะกลายเป็นความน่าจะเป็นที่จะไม่ถูกสังเกตหารด้วยความน่าจะเป็นที่จะถูกสังเกต ซึ่งคือ inverse odds ที่ใช้ต่อไปสำหรับการคัดเลือก
- การรักษา (treatment): คนที่หายไปคือผู้ป่วยกลุ่มเดียวกันภายใต้การรักษาอีกแบบหนึ่ง
- การเซ็นเซอร์ (censoring): คนที่หายไปคือผู้ที่สูญหายก่อนจะเห็นผลลัพธ์
- การคัดเลือก (selection): คนที่หายไปคือผู้ที่ไม่เคยเข้าร่วมการศึกษา
แบบแรก: การรักษาที่ผู้ป่วยไม่ได้รับ
การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นในการได้รับการรักษา (inverse probability of treatment weighting, IPTW) เป็นสมาชิกที่รู้จักกันดีที่สุด ให้ $A$ เป็นการรักษา โดย 1 คือผ่าตัดเร็ว และ 0 คือผ่าตัดช้ากว่า คะแนนแนวโน้มการได้รับการรักษา (propensity score) $e(X) = P(A = 1 \mid X)$ คือความน่าจะเป็นที่จะได้ผ่าตัดเร็ว เมื่อกำหนด $X$ ผู้ป่วยที่ได้ผ่าตัดเร็วได้น้ำหนัก $1/e(X)$ และผู้ป่วยที่ได้ผ่าตัดช้ากว่าได้น้ำหนัก $1/(1 - e(X))$
ในกลุ่มตัวอย่างที่ถ่วงน้ำหนักแล้ว ซึ่งเรียกว่าประชากรเสมือน (pseudo-population) ตัวแปรร่วมที่วัดได้จะไม่ทำนายการรักษาอีกต่อไป การเปรียบเทียบในกลุ่มนี้เป็นเชิงสาเหตุภายใต้ consistency (ผลลัพธ์ที่สังเกตได้คือผลลัพธ์ภายใต้การรักษาที่ได้รับ) และ ความแลกเปลี่ยนกันได้ (exchangeability) (ไม่มีตัวกวนที่ไม่ได้วัด) นอกจากนี้ยังต้องมี positivity (ทุกรูปแบบของตัวแปรร่วมมีโอกาสที่ไม่เป็นศูนย์ที่จะได้รับการรักษาแต่ละแบบ) และแบบจำลองคะแนนแนวโน้มที่ถูกต้อง ศูนย์กลางของชุดบทความนี้คือ IPTW: สร้างการเปรียบเทียบขึ้นใหม่ให้ใกล้เคียงกับที่การทดลองแบบสุ่มจะให้ ซึ่งสร้างน้ำหนักเหล่านี้อย่างครบถ้วน
แบบที่สอง: การติดตามที่สูญหายไป
ผู้ป่วยที่สูญหายจากการติดตามก่อนครบหนึ่งปีถูกเซ็นเซอร์ (censored) คือไม่ทราบผลลัพธ์ที่หนึ่งปี ให้ $C_i(t)$ เป็นตัวบ่งชี้การเซ็นเซอร์ โดยเป็น 1 หากผู้ป่วย $i$ สูญหายไปแล้วภายในเวลา $t$ และเป็น 0 ในกรณีอื่น
การตัดผู้ป่วยที่สูญหายออกทำให้ความเสี่ยงที่หนึ่งปีเอนเอียง แม้การสูญหายจะไม่เกี่ยวกับผลลัพธ์ ผู้ป่วยที่เสียชีวิตเร็วถูกสังเกตก่อนจะมีเวลามากพอให้สูญหาย ส่วนผู้รอดชีวิตต้องอยู่ในการติดตามตลอดทั้งปี ผู้ป่วยที่ถูกเก็บไว้จึงมีผู้เสียชีวิตมากเกินจริง เส้นโค้ง Kaplan-Meier ซึ่งเป็นเส้นโค้งการรอดชีพมาตรฐาน คงผู้ป่วยที่สูญหายแต่ละคนไว้ในการวิเคราะห์จนถึงเวลาที่สูญหาย จึงแก้ความเอนเอียงแบบแรกนี้ได้ การเซ็นเซอร์เป็นแบบ informative (สัมพันธ์กับผลลัพธ์) เมื่อโอกาสที่จะสูญหายขึ้นกับสิ่งที่ทำนายผลลัพธ์ด้วย เช่น ภาวะสมองเสื่อม การสูญหายแบบนี้เพิ่มความเอนเอียงอีกชั้นที่ Kaplan-Meier แก้ไม่ได้
การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นที่จะไม่ถูกเซ็นเซอร์ (inverse probability of censoring weighting, IPCW) ถ่วงน้ำหนักผู้ป่วยแต่ละคนที่เห็นผลลัพธ์ด้วยหนึ่งส่วนความน่าจะเป็นที่จะยังอยู่ในการติดตาม [2]:
$$w^C_i(t) = \frac{1}{P(C_i(t) = 0 \mid X_i, A_i)}$$ในสมการนี้ $t$ คือเวลาสิ้นสุดการติดตามของผู้ป่วยเอง (การเสียชีวิต หรือหนึ่งปีสำหรับผู้รอดชีวิต) และ $A_i$ คือการรักษาที่ได้รับ ผู้รอดชีวิตมีเวลาทั้งปีที่อาจสูญหายได้ จึงได้น้ำหนักมากกว่าผู้ป่วยที่คล้ายกันซึ่งเสียชีวิตเร็ว ซึ่งแก้ความเอนเอียงแบบแรก เมื่อใส่ภาวะสมองเสื่อมไว้ในแบบจำลองการเซ็นเซอร์ ผู้ป่วยที่มีภาวะสมองเสื่อมและยังอยู่ในการติดตามจะเป็นตัวแทนของผู้ป่วยที่คล้ายกันซึ่งสูญหายไป ซึ่งแก้ความเอนเอียงแบบที่สอง
ในที่นี้ซึ่งการรักษาถูกกำหนดไว้ตั้งแต่จุดเริ่มต้น การเซ็นเซอร์ไม่ใช่ตัวกวน (confounder) มันคือการสูญเสียข้อมูลผลลัพธ์หลังการรักษา เป็นอคติจากการคัดเลือก (selection bias) รูปแบบหนึ่งที่การถ่วงน้ำหนักแก้ได้เมื่อวัดตัวทำนายของการสูญหายไว้ [3]
การเสียชีวิตภายในหนึ่งปีในทะเบียนจำลอง
นอกการทดลอง ทะเบียนมีผู้ป่วย 19,050 คน และ 2,859 คนสูญหายก่อนครบหนึ่งปี (ข้อมูลจำลอง) ในการจำลองนี้ การสูญหายขึ้นกับภาวะสมองเสื่อมเท่านั้น และภาวะสมองเสื่อมยังเพิ่มความเสี่ยงการเสียชีวิตด้วย แบบจำลองการเซ็นเซอร์เป็นแบบจำลองเอกซ์โพเนนเชียล (exponential model) ซึ่งมีอัตราการสูญหายคงที่ตามเวลา โดยใช้ภาวะสมองเสื่อม อายุ และการผ่าตัดเร็ว และน้ำหนักการเซ็นเซอร์ที่ใหญ่ที่สุดคือ 1.64
น้ำหนักการเซ็นเซอร์แก้อคติจากการเซ็นเซอร์ ไม่ได้แก้ตัวกวน IPCW เพียงอย่างเดียวจึงถูกตัดสินเทียบกับความสัมพันธ์ที่ไม่มีการเซ็นเซอร์ คือผลต่างระหว่างสองกลุ่มหากไม่มีใครสูญหาย ส่วนน้ำหนักการรักษาคูณน้ำหนักการเซ็นเซอร์ถูกตัดสินเทียบกับผลเฉลี่ยของการรักษาในประชากรทั้งหมด (average treatment effect, ATE) เชิงสาเหตุ คือผ่าตัดเร็วเทียบกับผ่าตัดช้ากว่าสำหรับผู้ป่วยทุกคนนอกการทดลอง
น้ำหนักการรักษามาจากแบบจำลองโลจิสติกของคะแนนแนวโน้มที่ประมาณนอกการทดลอง รูปแบบของแบบจำลองนี้ตรงกับวิธีที่ใช้สร้างการผ่าตัดเร็วในการจำลองนี้ ในที่นี้แบบจำลองจึงระบุถูกต้อง (correctly specified) ตั้งแต่การออกแบบการจำลอง ตัวแปรร่วมของมันคืออายุ อายุยกกำลังสอง เพศ ภาวะเปราะบาง (frailty) ภาวะสมองเสื่อม ภาวะเปราะบางคูณภาวะสมองเสื่อม การใช้ยาต้านการแข็งตัวของเลือด และสถานะทางกายภาพตาม ASA ระดับ 3 ขึ้นไป (โรคระบบรุนแรงหรือรุนแรงกว่า)
การเสียชีวิตภายในหนึ่งปี ผ่าตัดเร็วลบผ่าตัดช้ากว่า: สี่วิธีวิเคราะห์
| วิธีวิเคราะห์ | ผลต่างความเสี่ยง (ช่วงเชื่อมั่น 95%) | ค่าจริงที่ใช้เทียบ |
|---|---|---|
| วิเคราะห์เฉพาะผู้ที่ตามครบ (complete case): ตัดผู้ป่วย 2,859 คนที่สูญหายออก | -0.181 (-0.194 ถึง -0.168) | -0.175 ความสัมพันธ์ที่ไม่มีการเซ็นเซอร์ |
| ความเสี่ยงแบบ Kaplan-Meier (ผ่าตัดเร็ว 0.157 ผ่าตัดช้ากว่า 0.324) | -0.167 | -0.175 ความสัมพันธ์ที่ไม่มีการเซ็นเซอร์ |
| IPCW | -0.174 (-0.187 ถึง -0.162) | -0.175 ความสัมพันธ์ที่ไม่มีการเซ็นเซอร์ |
| IPTW (น้ำหนัก ATE) คูณ IPCW | -0.035 (-0.060 ถึง -0.010) | -0.038 ผลเชิงสาเหตุ (ATE) |
แบบที่สาม: คนที่การทดลองไม่เคยรับเข้า
ลักษณะที่ทำให้ขนาดของผลการรักษาเปลี่ยนไป บนมาตรวัดที่รายงาน เรียกว่าตัวปรับผล (effect modifier) เมื่อตัวปรับผลมีการกระจายต่างกันระหว่างการทดลองกับประชากรที่สนใจ ผลของการทดลองอาจไม่เป็นจริงในประชากรนั้น
ให้ $S$ เป็นตัวบ่งชี้การเข้าร่วม โดยเป็น 1 สำหรับผู้เข้าร่วมการทดลอง และ 0 สำหรับสมาชิกของประชากรต้นทาง (ในที่นี้คือทะเบียน) ที่ไม่ได้เข้าร่วม คะแนนการถูกคัดเลือกเข้าการศึกษา (sampling score) คือความน่าจะเป็นที่จะเข้าร่วม เมื่อกำหนดตัวแปรร่วม [4, 5]:
$$s(X) = P(S = 1 \mid X)$$มักประมาณด้วยการถดถอยโลจิสติกของ $S$ บน $X$ โดยใช้ผู้เข้าร่วมและผู้ที่ไม่ได้เข้าร่วมรวมกัน น้ำหนักการคัดเลือก (selection weight) จึงถ่วงน้ำหนักผู้เข้าร่วมแต่ละคนด้วยฟังก์ชันของ $s(X)$ ฟังก์ชันนั้นขึ้นกับประชากรเป้าหมาย ดังนั้นต้องระบุเป้าหมายก่อน
เป้าหมาย: ประชากรต้นทางทั้งหมด รวมผู้เข้าร่วมด้วย น้ำหนักคือ
$$w^S_i = \frac{1}{s(X_i)}$$ผู้เข้าร่วมแต่ละคนจึงเป็นตัวแทนของคนที่คล้ายกัน $1/s(X_i)$ คน วิธีนี้คือการถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นในการถูกคัดเลือก (inverse probability of selection weighting, IPSW) หรือเรียกว่า inverse probability of sampling weighting ก็ได้ ใช้เพื่อการนำผลไปใช้กับประชากรต้นทาง (generalisability) คือการนำผลไปใช้กับประชากรที่การทดลองถูกสุ่มมา [4, 6]
เป้าหมาย: ผู้ที่ไม่ได้เข้าร่วม หรือประชากรภายนอก น้ำหนักคือ inverse odds ของการเข้าร่วม:
$$w^S_i = \frac{1 - s(X_i)}{s(X_i)}$$ผู้เข้าร่วมแต่ละคนจึงเป็นตัวแทนเฉพาะคนที่คล้ายกันซึ่งไม่ได้เข้าร่วม inverse odds of sampling weights เหล่านี้ใช้เพื่อการถ่ายโอนผล (transportability) คือการนำผลไปใช้กับผู้ที่อยู่นอกการทดลอง [7] สำหรับเป้าหมายภายนอก $s(X)$ มาจากข้อมูลของการทดลองรวมกับตัวอย่างของประชากรนั้นในชุดข้อมูลเดียว
ตัวอย่างคำนวณด้วยมือ: การทดลองหนึ่ง ประชากรเป้าหมายสามแบบ
ประชากรต้นทาง 1,000 คน มีผู้ป่วยอายุน้อย 500 คน และผู้ป่วยอายุมาก 500 คน คะแนนการถูกคัดเลือกเท่ากับ 0.80 สำหรับผู้ป่วยอายุน้อย และ 0.10 สำหรับผู้ป่วยอายุมาก การทดลองจึงรับผู้ป่วยอายุน้อย 400 คนและผู้ป่วยอายุมาก 50 คน รวม 450 คน ทั้งในและนอกการทดลอง การผ่าตัดเร็วเปลี่ยนความเสี่ยงของภาวะสับสนเฉียบพลันไป -0.05 ในผู้ป่วยอายุน้อย และ -0.15 ในผู้ป่วยอายุมาก อายุจึงเป็นตัวปรับผล (effect modifier)
-
ผลต่างความเสี่ยงของการทดลองเอง
\[ \frac{400 \times (-0.05) + 50 \times (-0.15)}{450} = \frac{-20 - 7.5}{450} = \frac{-27.5}{450} = -0.061 \]
ผู้เข้าร่วมการทดลองเป็นผู้ป่วยอายุน้อย 88.9% ผลของการทดลองจึงอยู่ใกล้ค่าของผู้ป่วยอายุน้อย
-
น้ำหนักสำหรับประชากรต้นทางทั้งหมด
\[ w^S = \frac{1}{0.80} = 1.25, \quad w^S = \frac{1}{0.10} = 10 \]
น้ำหนักแรกเป็นของผู้ป่วยอายุน้อย และน้ำหนักที่สองเป็นของผู้ป่วยอายุมาก จากนั้น 400 × 1.25 = 500 และ 50 × 10 = 500 การทดลองที่ถ่วงน้ำหนักแล้วจึงสร้างคนทั้ง 1,000 คนขึ้นใหม่
-
ผลต่างความเสี่ยงในประชากรต้นทางทั้งหมด
\[ \frac{500 \times (-0.05) + 500 \times (-0.15)}{1000} = \frac{-25 - 75}{1000} = \frac{-100}{1000} = -0.100 \]
นี่คือผลที่นำไปใช้กับประชากรต้นทาง
-
น้ำหนัก inverse odds สำหรับผู้ที่ไม่ได้เข้าร่วม
\[ \frac{1 - 0.80}{0.80} = 0.25, \quad \frac{1 - 0.10}{0.10} = 9 \]
น้ำหนักแรกเป็นของผู้ป่วยอายุน้อย และน้ำหนักที่สองเป็นของผู้ป่วยอายุมากเช่นเดิม จากนั้น 400 × 0.25 = 100 และ 50 × 9 = 450 คือ 550 คนที่ไม่ได้เข้าร่วม เป็นผู้ป่วยอายุน้อย 18.2% และอายุมาก 81.8%
-
ผลต่างความเสี่ยงในผู้ที่ไม่ได้เข้าร่วม
\[ \frac{100 \times (-0.05) + 450 \times (-0.15)}{550} = \frac{-5 - 67.5}{550} = \frac{-72.5}{550} = -0.132 \]
นี่คือผลที่ถ่ายโอนไปยังผู้ที่ไม่ได้เข้าร่วม
ผลลัพธ์: การทดลองหนึ่งให้ผลสามค่า คือ -0.061 สำหรับผู้เข้าร่วม -0.100 สำหรับประชากรต้นทางทั้งหมด และ -0.132 สำหรับผู้ที่ไม่ได้เข้าร่วม แต่ละค่าถูกต้องสำหรับเป้าหมายของตัวเอง ผลที่ถ่วงน้ำหนักทั้งสองค่าถูกต้องก็ต่อเมื่ออายุซึ่งเป็นตัวปรับผล (effect modifier) อยู่ในแบบจำลองการคัดเลือก
ทะเบียนจำลอง: นำผลการทดลองไปสู่เป้าหมายสองแบบ
ในทะเบียนจำลอง การทดลอง 950 คนวัดภาวะสับสนเฉียบพลันหลังผ่าตัด แบบจำลองการคัดเลือกถดถอยการเข้าร่วมการทดลองบนอายุ เพศ ภาวะเปราะบาง ภาวะสมองเสื่อม การใช้ยาต้านการแข็งตัวของเลือด และสถานะทางกายภาพตาม ASA ระดับ 3 ขึ้นไป ในผู้ป่วยทั้ง 20,000 คน โดยการออกแบบ ผลของการผ่าตัดเร็วต่อภาวะสับสนเฉียบพลันต่างกันตามภาวะเปราะบางเท่านั้นบนมาตราส่วนของ odds ratio บนมาตราส่วนผลต่างความเสี่ยงที่รายงานในที่นี้ ผลนี้เปลี่ยนไปตามปัจจัยเสี่ยงทุกตัวของภาวะสับสนเฉียบพลันด้วย และปัจจัยทั้งหมดที่ขับเคลื่อนการเข้าร่วมการทดลองอยู่ในแบบจำลองแล้ว
แบบจำลองเดียวให้น้ำหนักสองแบบ คือ $1/s(X)$ สำหรับทะเบียนทั้งหมด และ inverse odds สำหรับผู้ที่ไม่ได้เข้าร่วม 19,050 คน ผลต่างความเสี่ยงแต่ละค่ามาจากการถดถอยเชิงเส้นถ่วงน้ำหนักพร้อมค่าคลาดเคลื่อนมาตรฐานแบบแซนด์วิช (robust standard error) ซึ่งประมาณจากส่วนเหลือที่สังเกตได้แทนสูตรความแปรปรวนของแบบจำลอง robust standard error ช่วยแก้ค่าคลาดเคลื่อนมาตรฐานเมื่อข้อสมมติเรื่องความแปรปรวนผิด แต่ไม่ได้แก้แบบจำลองค่าเฉลี่ยที่ผิด และสัมประสิทธิ์ที่ลำเอียงก็ยังคงลำเอียงอยู่
ในที่นี้ ค่าคลาดเคลื่อนมาตรฐานแบบ robust ยังถือว่าน้ำหนักเป็นค่าที่ทราบ โดยไม่นับว่า $s(X)$ เองถูกประมาณมา ขนาดตัวอย่างประสิทธิผล (effective sample size, ESS) คือ $(\sum w)^2 / \sum w^2$ โดยประมาณคือจำนวนผู้ป่วยที่ถ่วงน้ำหนักเท่ากันซึ่งให้ความแม่นยำเท่ากัน
ภาวะสับสนเฉียบพลันในการทดลองที่ซ้อนอยู่: ผลต่างความเสี่ยงตามเป้าหมาย
| น้ำหนัก (เป้าหมาย) | ผลต่างความเสี่ยง ผ่าตัดเร็วลบผ่าตัดช้ากว่า (ช่วงเชื่อมั่น 95%) | ค่าจริงในเป้าหมาย | SE แบบ robust | ESS (จาก 950) | น้ำหนักที่ใหญ่ที่สุด |
|---|---|---|---|---|---|
| ไม่ถ่วงน้ำหนัก (ผู้เข้าร่วม 950 คน) | -0.053 (-0.092 ถึง -0.014) | -0.033 | 0.020 | 950 | 1 |
| $1/s(X)$ (ทะเบียนทั้งหมด 20,000 คน) | -0.053 (-0.221 ถึง 0.114) | -0.038 | 0.085 | 199 | 695 |
| Inverse odds (ผู้ที่ไม่ได้เข้าร่วม 19,050 คน) | -0.052 (-0.226 ถึง 0.122) | -0.038 | 0.089 | 184 | 694 |
ตารางนี้บอกอะไร: ราคาที่ต้องจ่าย ไม่ใช่คำตอบใหม่
ค่าประมาณทั้งสามเกือบเท่ากัน และเป้าหมายทั้งสามต่างกันไม่ถึงหนึ่งจุดร้อยละ ครึ่งความกว้างของช่วงเชื่อมั่นของค่าที่ถ่วงน้ำหนักคือ 0.168 และ 0.174 เทียบกับ 0.039 ของการทดลองเพียงอย่างเดียว ข้อมูลเหล่านี้จึงแยกเป้าหมายออกจากกันไม่ได้ สิ่งที่ข้อมูลแสดงคือราคาของการถ่ายโอนผล คือค่าคลาดเคลื่อนมาตรฐานเป็น 4.31 เท่าของการทดลองสำหรับ $1/s(X)$ และ 4.46 เท่าสำหรับ inverse odds
ราคานี้มาจากน้ำหนักที่สูงมากไม่กี่ตัว ผู้ป่วยอายุมากและผู้ป่วยที่มีภาวะสมองเสื่อมแทบไม่ได้เข้าร่วมการทดลอง ผู้ที่เข้าร่วมไม่กี่คนจึงเป็นตัวแทนของผู้ป่วยในทะเบียนจำนวนมาก สูงสุดถึง 695 คนต่อคน น้ำหนักที่สูงขนาดนี้บ่งชี้ผู้ป่วยที่การทดลองแทบไม่เคยรับเข้า ซึ่งเกือบละเมิด positivity ของการเข้าร่วมการทดลอง ESS ลดลงเหลือ 199 และ 184 จาก 950 หรือประมาณหนึ่งในห้าของการทดลอง
สคริปต์ Stata และ R ด้านล่างคือโค้ดจำลองฉบับเต็มของทะเบียนนี้ ซึ่งใช้ร่วมกันทั้งชุดบทความนี้และบทความอื่น สำหรับบทความนี้ ให้อ่านเฉพาะบรรทัดของคะแนนการถูกคัดเลือก น้ำหนัก และผลต่างความเสี่ยง บรรทัดที่พิมพ์คำว่า CANON มีไว้เพียงเขียนผลแต่ละค่าเป็นข้อความธรรมดา และบรรทัด VERIFY มีไว้เพียงตรวจว่าติดตั้งซอฟต์แวร์แล้ว
ค่าจริงของข้อมูลจำลองอ่านมาจากไฟล์การตั้งค่าของการจำลองเอง ซึ่งไม่ได้เผยแพร่ ค่าจริงเหล่านี้ถูกกำหนดไว้แล้วจากวิธีที่สร้างข้อมูล จึงควรถือว่าเป็นคำตอบที่ทราบอยู่แล้วซึ่งใช้เทียบค่าประมาณ ไม่ใช่ผลที่คำนวณซ้ำได้จากผลลัพธ์ที่พิมพ์ออกมา
Stata: แบบจำลองคะแนนการถูกคัดเลือก น้ำหนัก และค่าประมาณของการทดลอง
* Simulated hip-fracture registry of older adults: surgery within 24 hours (surg24), delirium, one-year
* death, a small nested randomised trial (trial = 1) and a delirium risk model with albumin partly missing.
* Simulated data: not evidence about any real drug or patient.
* The simulated registry files (not published) are read from a folder two levels above this script.
version 18
clear all
set more off
set linesize 120
set seed 202610
* number of imputations m: at least the percentage of incomplete rows (albumin is missing in about 30 percent)
global MI_M 40
* ---- commands this file relies on (all ship with Stata) ----
foreach c in teffects tebalance glm margins nlcom stcox streg mi roctab {
capture which `c'
display "VERIFY `c' " cond(_rc == 0, "available", "missing")
}
* ---- helpers: print each result on its own CANON line, rounded to 4 decimals ----
program define canon
gettoken key 0 : 0
display "CANON w1.`key' " strtrim(string(`0', "%20.4f"))
end
program define canonn
gettoken key 0 : 0
display "CANON w1.`key' " strtrim(string(`0', "%20.0f"))
end
* estimate and 95% CI from the last regression: normal (z) or t with the model's residual df
program define ciz
args key coef tr
scalar sc_b = _b[`coef']
scalar sc_se = _se[`coef']
scalar sc_q = invnormal(0.975)
if "`tr'" == "t" {
scalar sc_q = invttail(e(df_r), 0.025)
}
if "`tr'" == "exp" {
canon `key' exp(scalar(sc_b))
canon `key'.lo exp(scalar(sc_b) - scalar(sc_q)*scalar(sc_se))
canon `key'.hi exp(scalar(sc_b) + scalar(sc_q)*scalar(sc_se))
}
else {
canon `key' scalar(sc_b)
canon `key'.lo scalar(sc_b) - scalar(sc_q)*scalar(sc_se)
canon `key'.hi scalar(sc_b) + scalar(sc_q)*scalar(sc_se)
}
end
* Kish effective sample size, (sum w)^2 / sum w^2 (left in scalar sc_ess as well)
program define essprint
syntax varname [if], key(string)
tempvar sq
quietly generate double `sq' = `varlist'^2 `if'
quietly summarize `varlist' `if', meanonly
scalar sc_s1 = r(sum)
quietly summarize `sq' `if', meanonly
scalar sc_ess = scalar(sc_s1)^2 / r(sum)
canon `key' scalar(sc_ess)
end
* risk ratio from a weighted log-link Poisson model, robust SE treating the weights as known
program define wpoisrr
syntax varname [if], key(string)
quietly glm delirium surg24 [pw = `varlist'] `if', family(poisson) link(log) vce(robust)
glm
ciz `key' surg24 exp
end
* risk ratio of the two weighted risks after teffects ipw ..., pomeans (delta method on the log ratio,
* joint M-estimation variance that accounts for estimating e(X))
program define pomrr
args key
matrix B = e(b)
matrix V = e(V)
matrix G = (-1/B[1,1], 1/B[1,2])
matrix VG = G*V[1..2,1..2]*G'
scalar sc_b = ln(B[1,2]/B[1,1])
scalar sc_se = sqrt(VG[1,1])
canon `key' exp(scalar(sc_b))
canon `key'.lo exp(scalar(sc_b) - invnormal(0.975)*scalar(sc_se))
canon `key'.hi exp(scalar(sc_b) + invnormal(0.975)*scalar(sc_se))
end
* simulated truth: the values the simulation was built to produce, read from its settings file (not
* published) and printed as w1.truth.<name>; an optional anchor names the block the value sits in
mata:
real scalar truthval(string scalar f, string scalar k, | string scalar anchor)
{
string colvector L
string scalar pat, s
real scalar i, p, i0
L = cat(f)
i0 = 1
if (args() < 3) anchor = ""
if (anchor != "") {
for (i = 1; i <= rows(L); i++) {
if (strpos(L[i], char(34) + anchor + char(34) + ":") > 0) {
i0 = i
break
}
}
}
pat = char(34) + k + char(34) + ":"
for (i = i0; i <= rows(L); i++) {
p = strpos(L[i], pat)
if (p > 0) {
s = substr(L[i], p + strlen(pat), .)
s = subinstr(s, ",", "", .)
return(strtoreal(strtrim(s)))
}
}
return(.)
}
end
program define truthecho
local tj ../../datasets/W1/truth.json
foreach nm of local 0 {
mata: st_numscalar("sc_tv", truthval("`tj'", "`nm'"))
canon truth.`nm' scalar(sc_tv)
}
end
* one value inside a named block of the settings file: truthat <block> <name in the block> <printed name>
program define truthat
args block nm key
local tj ../../datasets/W1/truth.json
mata: st_numscalar("sc_tv", truthval("`tj'", "`nm'", "`block'"))
canon truth.`key' scalar(sc_tv)
end
* calibration of a linear predictor lp on the outcome: the slope is the coefficient of lp in a logistic
* regression of delirium on lp; calibration-in-the-large (CITL) is the intercept when lp enters as an
* offset (slope fixed at 1). Ideal values: slope 1, CITL 0.
program define calfit, rclass
args lp
quietly logit delirium `lp'
return scalar slope = _b[`lp']
return scalar slope_se = _se[`lp']
quietly logit delirium, offset(`lp')
return scalar citl = _b[_cons]
return scalar citl_se = _se[_cons]
end
* print slope and CITL with normal 95% CIs as <key>.slope, <key>.citl (and .lo, .hi), keep a row for the table
program define calprint
args key lp sfx
calfit `lp'
scalar sc_q = invnormal(0.975)
scalar sc_s = r(slope)
scalar sc_sse = r(slope_se)
scalar sc_c = r(citl)
scalar sc_cse = r(citl_se)
matrix CALROW = (scalar(sc_s), scalar(sc_s) - scalar(sc_q)*scalar(sc_sse), scalar(sc_s) + scalar(sc_q)*scalar(sc_sse), scalar(sc_c), scalar(sc_c) - scalar(sc_q)*scalar(sc_cse), scalar(sc_c) + scalar(sc_q)*scalar(sc_cse))
calcanon `key' "`sfx'"
end
* the CANON lines and the table row from the 1 x 6 matrix CALROW (slope, lo, hi, CITL, lo, hi)
program define calcanon
args key sfx
canon `key'.slope`sfx' CALROW[1,1]
canon `key'.slope.lo`sfx' CALROW[1,2]
canon `key'.slope.hi`sfx' CALROW[1,3]
canon `key'.citl`sfx' CALROW[1,4]
canon `key'.citl.lo`sfx' CALROW[1,5]
canon `key'.citl.hi`sfx' CALROW[1,6]
matrix CAL = nullmat(CAL) \ CALROW
end
* Rubin's rules over the m rows of a matrix holding (slope, SE, CITL, SE) per imputed dataset: pooled
* estimate, total variance W + (1 + 1/m) B, and a t-based 95% CI with the large-sample Rubin df; the
* result goes to CALROW (slope, lo, hi, CITL, lo, hi)
mata:
void rubin_cal(string scalar nm)
{
real matrix X
real rowvector out
real scalar j, M, q, w, b, t, df, c
X = st_matrix(nm)
M = rows(X)
out = J(1, 6, .)
for (j = 0; j <= 1; j++) {
q = sum(X[., 2*j + 1]) / M
w = sum(X[., 2*j + 2]:^2) / M
b = sum((X[., 2*j + 1] :- q):^2) / (M - 1)
t = w + (1 + 1/M)*b
df = (M - 1)*(1 + w/((1 + 1/M)*b))^2
c = invttail(df, 0.025)
out[3*j + 1] = q
out[3*j + 2] = q - c*sqrt(t)
out[3*j + 3] = q + c*sqrt(t)
}
st_matrix("CALROW", out)
}
end
* linear predictor of the complete-case delirium model with a chosen albumin variable
program define lpcc
args newvar albvar
generate double `newvar' = scalar(cc__cons) + scalar(cc_age_c)*age_c + scalar(cc_female)*female + scalar(cc_frail)*frail + scalar(cc_dementia)*dementia + scalar(cc_asa3)*asa3 + scalar(cc_anticoag)*anticoag + scalar(cc_albumin)*`albvar'
end
* apparent development AUROC of a pooled MI model (posted by mi estimate, post), averaged over the m completed sets
program define mi_devauc
args key
tempvar lp0 lpm
quietly generate double `lp0' = _b[_cons] + _b[age_c]*age_c + _b[female]*female + _b[frail]*frail + _b[dementia]*dementia + _b[asa3]*asa3 + _b[anticoag]*anticoag
scalar sc_auc = 0
forvalues m = 1/$MI_M {
capture drop `lpm'
quietly generate double `lpm' = `lp0' + _b[albumin]*_`m'_albumin
quietly roctab delirium `lpm'
scalar sc_auc = scalar(sc_auc) + r(area)/$MI_M
}
canon `key' scalar(sc_auc)
end
* ---- data: the simulated registry file (not published) ----
import delimited using ../../datasets/W1/W1.csv, clear varnames(1) asdouble
generate double age_c = age - 80
generate double age_c2 = age_c^2
generate double fd = frail*dementia
tempfile full
quietly save `full'
canonn n _N
quietly count if trial == 1
canonn n.trial r(N)
quietly count if trial == 0
canonn n.obs r(N)
quietly count if missing(albumin)
scalar sc_nmiss = r(N)
canonn n.albumin_missing scalar(sc_nmiss)
* share of registry rows with albumin missing, and the number of imputations m used below
canon n.albumin_missing_frac scalar(sc_nmiss)/_N
canonn e1.mi_m $MI_M
* the simulated validation file (not published)
preserve
import delimited using ../../datasets/W1/W1_validation.csv, clear varnames(1) asdouble
canonn n.validation _N
restore
* observational part: treatment chosen by clinicians
keep if trial == 0
quietly count if surg24 == 1
canonn n.obs.treated r(N)
quietly count if died == 1
canonn n.obs.died r(N)
quietly count if lost == 1
canonn n.obs.lost r(N)
* ---- crude comparison of delirium ----
regress delirium surg24, vce(robust)
ciz crude.del.rd surg24 t
* ---- propensity score models: main effects only versus the true form (tight tolerances) ----
logit surg24 age_c female frail dementia asa3 anticoag, tolerance(1e-10) ltolerance(1e-12) nrtolerance(1e-10)
predict double ps_mis, pr
logit surg24 age_c age_c2 female frail dementia fd asa3 anticoag, tolerance(1e-10) ltolerance(1e-12) nrtolerance(1e-10)
predict double ps_cor, pr
generate double w_raw = 1
generate double w_mis = cond(surg24 == 1, 1/ps_mis, 1/(1 - ps_mis))
generate double w_cor = cond(surg24 == 1, 1/ps_cor, 1/(1 - ps_cor))
generate double w_att = cond(surg24 == 1, 1, ps_cor/(1 - ps_cor))
generate double w_ato = cond(surg24 == 1, 1 - ps_cor, ps_cor)
summarize ps_cor
canon ps.min r(min)
canon ps.max r(max)
quietly count if ps_cor < 0.05
canonn ps.n_below_005 r(N)
* ---- standardised mean differences: weighted means, unweighted pooled SD in the denominator ----
foreach lab in raw mis cor {
scalar sc_max = 0
foreach x of varlist age_c age_c2 female frail dementia fd asa3 anticoag {
quietly summarize `x' if surg24 == 1
scalar sc_v1 = r(Var)
quietly summarize `x' if surg24 == 0
scalar sc_v0 = r(Var)
quietly summarize `x' [aw = w_`lab'] if surg24 == 1
scalar sc_m1 = r(mean)
quietly summarize `x' [aw = w_`lab'] if surg24 == 0
scalar sc_m0 = r(mean)
scalar sc_smd = (sc_m1 - sc_m0)/sqrt((sc_v1 + sc_v0)/2)
canon smd.`lab'.`x' scalar(sc_smd)
scalar sc_max = max(sc_max, abs(sc_smd))
}
canon smd.`lab'.maxabs scalar(sc_max)
}
* ---- weights: raw, stabilised, truncated at the 1st and 99th percentiles ----
summarize surg24, meanonly
scalar sc_pa = r(mean)
generate double w_sw = w_cor*cond(surg24 == 1, sc_pa, 1 - sc_pa)
_pctile w_cor, p(1 99)
scalar sc_p01 = r(r1)
scalar sc_p99 = r(r2)
canon wt.trunc.p01 scalar(sc_p01)
canon wt.trunc.p99 scalar(sc_p99)
generate double w_trunc = min(max(w_cor, sc_p01), sc_p99)
foreach lab in cor sw trunc {
local nm = cond("`lab'" == "cor", "raw", "`lab'")
summarize w_`lab', meanonly
canon wt.`nm'.max r(max)
essprint w_`lab', key(wt.`nm'.ess)
essprint w_`lab' if surg24 == 1, key(wt.`nm'.ess_treated)
essprint w_`lab' if surg24 == 0, key(wt.`nm'.ess_control)
}
* ---- IPTW effects on delirium: teffects (M-estimation SE that accounts for estimating e(X)) ----
teffects ipw (delirium) (surg24 age_c age_c2 female frail dementia fd asa3 anticoag, logit), ate
matrix list e(b)
ciz ipw.del.ate ATE:r1vs0.surg24
* this interval accounts for estimating e(X); its SE is printed for comparison with the weights-known SE below
canon ipw.del.ate.se _se[ATE:r1vs0.surg24]
tebalance summarize
teffects ipw (delirium) (surg24 age_c age_c2 female frail dementia fd asa3 anticoag, logit), atet
ciz ipw.del.att ATET:r1vs0.surg24
teffects ipw (delirium) (surg24 age_c female frail dementia asa3 anticoag, logit), ate
ciz ipw.del.ate_mis ATE:r1vs0.surg24
tebalance summarize
* the two weighted risks and their ratio (delta method on the log ratio, joint M-estimation variance)
teffects ipw (delirium) (surg24 age_c age_c2 female frail dementia fd asa3 anticoag, logit), pomeans
matrix list e(b)
matrix B = e(b)
canon ipw.del.ate.risk1 B[1,2]
canon ipw.del.ate.risk0 B[1,1]
pomrr ipw.del.ate.rr
* misspecified score: the same risk ratio and M-estimation SE under the main-effects-only propensity model
teffects ipw (delirium) (surg24 age_c female frail dementia asa3 anticoag, logit), pomeans
pomrr ipw.del.ate_mis.rr
* ATO, stabilised, truncated and trimmed: weighted regression, robust SE treating weights as known
* (each weighted fit runs quietly and is then replayed, which shows the same table without the weight-sum note)
* raw ATE weights through the same weights-known estimator, so raw and stabilised compare like for like
quietly regress delirium surg24 [pw = w_cor], vce(robust)
regress
ciz ipw.del.ate_rawreg surg24 t
* weights-known robust SE, raw weights
canon ipw.del.ate_rawreg.se _se[surg24]
quietly regress delirium surg24 [pw = w_ato], vce(robust)
regress
ciz ipw.del.ato surg24 t
quietly regress delirium surg24 [pw = w_sw], vce(robust)
regress
ciz ipw.del.ate_sw surg24 t
* weights-known robust SE, stabilised weights
canon ipw.del.ate_sw.se _se[surg24]
quietly regress delirium surg24 [pw = w_trunc], vce(robust)
regress
ciz ipw.del.ate_trunc surg24 t
* trimming changes the population: keep 0.1 <= e(X) <= 0.9
quietly count if ps_cor >= 0.1 & ps_cor <= 0.9
canonn n.trim r(N)
quietly regress delirium surg24 [pw = w_cor] if ps_cor >= 0.1 & ps_cor <= 0.9, vce(robust)
regress
ciz ipw.del.ate_trim surg24 t
* risk ratios beside each weights-known risk difference (log-link Poisson, robust SE, weights known)
wpoisrr w_cor, key(ipw.del.ate_rawreg.rr)
wpoisrr w_sw, key(ipw.del.ate_sw.rr)
wpoisrr w_trunc, key(ipw.del.ate_trunc.rr)
wpoisrr w_cor if ps_cor >= 0.1 & ps_cor <= 0.9, key(ipw.del.ate_trim.rr)
wpoisrr w_ato, key(ipw.del.ato.rr)
* the true values these delirium estimates are compared with (simulated truth)
truthecho del_rd_ate_trial0 del_rr_ate_trial0 del_rd_att_trial0 del_rr_att_trial0 del_rd_ato_trial0 del_rr_ato_trial0
truthecho del_rd_trimmed_on_true_ps_trial0 del_rr_trimmed_on_true_ps_trial0 del_assoc_trial0_rd del_assoc_trial0_rr
* ---- 1-year death: crude Kaplan-Meier risk and crude Cox ----
stset fu_months, failure(died)
sts generate km = s, by(surg24)
sts generate kmse = se(s), by(surg24)
summarize km if surg24 == 0 & _t >= 12, meanonly
scalar sc_r0 = 1 - r(mean)
summarize kmse if surg24 == 0 & _t >= 12, meanonly
scalar sc_se0 = r(mean)
summarize km if surg24 == 1 & _t >= 12, meanonly
scalar sc_r1 = 1 - r(mean)
summarize kmse if surg24 == 1 & _t >= 12, meanonly
scalar sc_se1 = r(mean)
canon km.risk0 scalar(sc_r0)
canon km.risk1 scalar(sc_r1)
scalar sc_rd = sc_r1 - sc_r0
scalar sc_se = sqrt(sc_se0^2 + sc_se1^2)
canon crude.death.rd scalar(sc_rd)
canon crude.death.rd.lo scalar(sc_rd) - invnormal(0.975)*scalar(sc_se)
canon crude.death.rd.hi scalar(sc_rd) + invnormal(0.975)*scalar(sc_se)
stcox surg24, nohr
ciz crude.death.hr surg24 exp
* ---- inverse probability of censoring weights: exponential model for loss to follow-up ----
stset fu_months, failure(lost)
streg dementia age_c surg24, distribution(exponential) nohr
predict double xb_c, xb
* probability of still being followed at one's own end time, given X
generate double ipcw = 1/exp(-exp(xb_c)*fu_months)
summarize ipcw if lost == 0
canon ipcw.max r(max)
regress died surg24 if lost == 0, vce(robust)
ciz naive.death.rd surg24 t
quietly regress died surg24 [pw = ipcw] if lost == 0, vce(robust)
regress
ciz ipcw.death.rd surg24 t
* truth for the IPCW-only contrast: censoring-free ASSOCIATIONAL risks by arm in trial = 0 (no confounding control)
truthecho death_assoc_trial0_risk1 death_assoc_trial0_risk0 death_assoc_trial0_rd death_assoc_trial0_rr
* truth for the IPTW x IPCW contrasts below: the causal 1-year risk difference and risk ratio (ATE, trial = 0)
truthecho death_rd_ate_trial0 death_rr_ate_trial0
* ---- IPTW times IPCW: the 1-year risk difference for ATE, ATT and ATO ----
foreach lab in cor att ato {
local nm = cond("`lab'" == "cor", "ate", "`lab'")
generate double wd_`lab' = w_`lab'*ipcw
quietly regress died surg24 [pw = wd_`lab'] if lost == 0, vce(robust)
regress
ciz ipw.death.`nm' surg24 t
}
* ---- IPTW Cox model (ATE weights; pweights give a robust SE) ----
stset fu_months [pw = w_cor], failure(died)
stcox surg24, nohr
ciz ipw.death.hr surg24 exp
* ---- link and family: saturated versus adjusted fits (all registry rows) ----
use `full', clear
* saturated fits (surg24 only): log-binomial, modified Poisson, and Gaussian family with a log link
glm delirium surg24, family(binomial) link(log)
* model-based CI (identical bread in both languages for a saturated model)
ciz c2.rr_crude_logbin surg24 exp
* log-RR SE, log-binomial, model-based
canon c2.se_crude_logbin _se[surg24]
quietly glm delirium surg24, family(poisson) link(log)
scalar sc_pn = _se[surg24]
glm delirium surg24, family(poisson) link(log) vce(robust)
matrix bpc = e(b)
* modified Poisson, robust CI
ciz c2.rr_crude_poisson surg24 exp
* Poisson model-based log-RR SE (too large for a binary outcome) versus the sandwich log-RR SE
canon c2.se_crude_poisson_naive scalar(sc_pn)
canon c2.se_crude_poisson_robust _se[surg24]
* naive Poisson 95% CI
canon c2.rr_crude_poisson_naive.lo exp(_b[surg24] - invnormal(0.975)*scalar(sc_pn))
canon c2.rr_crude_poisson_naive.hi exp(_b[surg24] + invnormal(0.975)*scalar(sc_pn))
* Gaussian log link, robust CI (saturated: same bread in both languages)
glm delirium surg24, family(gaussian) link(log) vce(robust) from(bpc)
ciz c2.rr_crude_gaussian surg24 exp
* adjusted for age and sex
quietly glm delirium surg24 age_c female, family(poisson) link(log)
scalar sc_p2n = _se[surg24]
glm delirium surg24 age_c female, family(poisson) link(log) vce(robust)
matrix bp2 = e(b)
scalar sc_p2 = exp(_b[surg24])
scalar sc_p2lo = exp(_b[surg24] - invnormal(0.975)*_se[surg24])
scalar sc_p2hi = exp(_b[surg24] + invnormal(0.975)*_se[surg24])
scalar sc_p2r = _se[surg24]
* adjusted fits: the log-binomial starts from the Poisson fit
glm delirium surg24 age_c female, family(binomial) link(log) from(bp2)
canon c2.rr_adj2_logbin exp(_b[surg24])
* Stata's ML glm uses the observed-information SE for the non-canonical log link (R's glm: expected)
canon c2.rr_adj2_logbin.lo.stata exp(_b[surg24] - invnormal(0.975)*_se[surg24])
canon c2.rr_adj2_logbin.hi.stata exp(_b[surg24] + invnormal(0.975)*_se[surg24])
* log-RR SE, adjusted log-binomial (observed information)
canon c2.se_adj2_logbin.stata _se[surg24]
canon c2.rr_adj2_poisson scalar(sc_p2)
* modified Poisson, robust CI; model-based versus sandwich log-RR SE, adjusted
canon c2.rr_adj2_poisson.lo scalar(sc_p2lo)
canon c2.rr_adj2_poisson.hi scalar(sc_p2hi)
canon c2.se_adj2_poisson_naive scalar(sc_p2n)
canon c2.se_adj2_poisson_robust scalar(sc_p2r)
* Gaussian log link, adjusted for age and sex (observed-information bread here)
glm delirium surg24 age_c female, family(gaussian) link(log) vce(robust) from(bp2)
canon c2.rr_adj2_gaussian exp(_b[surg24])
canon c2.rr_adj2_gaussian.lo.stata exp(_b[surg24] - invnormal(0.975)*_se[surg24])
canon c2.rr_adj2_gaussian.hi.stata exp(_b[surg24] + invnormal(0.975)*_se[surg24])
* adjusted for age and frailty: the Poisson fit gives fitted risks above 1, so the log-binomial cannot start
quietly glm delirium surg24 age_c frail, family(poisson) link(log)
scalar sc_pafn = _se[surg24]
glm delirium surg24 age_c frail, family(poisson) link(log) vce(robust)
matrix bpaf = e(b)
ciz c2.rr_adjaf_poisson surg24 exp
* model-based versus sandwich log-RR SE, age and frailty
canon c2.se_adjaf_poisson_naive scalar(sc_pafn)
canon c2.se_adjaf_poisson_robust _se[surg24]
predict double mu_af, mu
summarize mu_af
* largest fitted risk: above 1 means the log-binomial wall
canon c2.adjaf_poisson_max_fitted r(max)
capture noisily glm delirium surg24 age_c frail, family(binomial) link(log) from(bpaf) iterate(100)
scalar sc_afconv = 0
if _rc == 0 {
scalar sc_afconv = e(converged)
}
* 1 = converged
canonn c2.adjaf_logbin_converged.stata scalar(sc_afconv)
* ---- the risk-ratio ladder for a common outcome (all registry rows, same covariates) ----
* rung 1: log-binomial; it stops when a covariate pattern would need a risk above 1
capture noisily glm delirium surg24 age_c female frail dementia asa3 anticoag, family(binomial) link(log) iterate(100)
scalar sc_rc = _rc
scalar sc_conv = 0
if sc_rc == 0 {
scalar sc_conv = e(converged)
}
display "NOTE log-binomial return code " sc_rc
canonn c3.logbin_converged scalar(sc_conv)
* rung 2: modified Poisson with robust SE
glm delirium surg24 age_c female frail dementia asa3 anticoag, family(poisson) link(log) vce(robust) eform
ciz c3.rr_poisson surg24 exp
matrix bp = e(b)
predict double mu_p, mu
summarize mu_p
canon c3.poisson_max_fitted r(max)
quietly count if mu_p > 1
canonn c3.poisson_n_fitted_above1 r(N)
* rung 3: Gaussian family with a LOG link and robust SE (starts from the Poisson fit)
glm delirium surg24 age_c female frail dementia asa3 anticoag, family(gaussian) link(log) vce(robust) from(bp) eform
* Stata's ML glm uses the observed-information bread here (non-canonical link), so its robust CI differs from R's
canon c3.rr_gaussian exp(_b[surg24])
canon c3.rr_gaussian.lo.stata exp(_b[surg24] - invnormal(0.975)*_se[surg24])
canon c3.rr_gaussian.hi.stata exp(_b[surg24] + invnormal(0.975)*_se[surg24])
* rung 4: logistic model, then marginal standardisation for the risk ratio and risk difference
logit delirium i.surg24 age_c female frail dementia asa3 anticoag
ciz c5.or_cond 1.surg24 exp
margins surg24, post
nlcom (rd: _b[1.surg24] - _b[0.surg24]) (lnrr: ln(_b[1.surg24]/_b[0.surg24])) (lnor: ln((_b[1.surg24]/(1 - _b[1.surg24]))/(_b[0.surg24]/(1 - _b[0.surg24])))), post
ciz c3.rr_std lnrr exp
ciz c3.rd_std rd
* the same model averaged over the cohort gives the marginal odds ratio (non-collapsibility)
ciz c5.or_marg lnor exp
* the realised nested trial (n = 950): one noisy draw. Chance covariate imbalance in a trial this small can
* outweigh non-collapsibility, so these keys are named "realised"; the large-sample pair is printed further down
logit delirium surg24 if trial == 1
ciz c5.trial_realised.or_crude surg24 exp
logit delirium i.surg24 age_c female frail dementia asa3 anticoag if trial == 1
ciz c5.trial_realised.or_adj 1.surg24 exp
* trial-standardised marginal OR: the adjusted trial model averaged over the trial participants
margins surg24, post
nlcom (lnor: ln((_b[1.surg24]/(1 - _b[1.surg24]))/(_b[0.surg24]/(1 - _b[0.surg24])))), post
ciz c5.trial_realised.or_std lnor exp
* large-sample simulated truth: conditional OR within frailty strata versus marginal ORs
* in trial participants, and the large-sample value of the six-covariate adjusted model in the trial
truthecho c5_or_cond_nonfrail c5_or_cond_frail c5_or_marg_trial_nonfrail c5_or_marg_trial_frail c5_or_marg_trial
truthecho c5_or_adj_pseudo_trial del_rr_whole_population del_rd_whole_population
* ---- prediction model for delirium with albumin missing: complete case versus MI without and with Y ----
logit delirium age_c female frail dementia asa3 anticoag albumin
canonn e1.cc.n e(N)
ciz e1.cc.b_albumin albumin
matrix b_cc = e(b)
foreach v in age_c female frail dementia asa3 anticoag albumin _cons {
scalar cc_`v' = _b[`v']
}
predict double lp_ccdev if e(sample), xb
roctab delirium lp_ccdev
scalar auc_dev_cc = r(area)
* apparent AUROC on the complete-case development rows
canon e1.cc.dev_auroc scalar(auc_dev_cc)
* deployment-style imputation model for albumin fitted on the development data, no outcome in it
regress albumin age_c female frail dementia asa3 anticoag
matrix b_ri = e(b)
keep id delirium age_c female frail dementia asa3 anticoag albumin
tempfile dev
quietly save `dev'
mi set wide
mi register imputed albumin
mi register regular delirium age_c female frail dementia asa3 anticoag
* imputation model WITHOUT the outcome
mi impute chained (pmm, knn(10)) albumin = age_c female frail dementia asa3 anticoag, add($MI_M) rseed(202610)
mi estimate, post: logit delirium age_c female frail dementia asa3 anticoag albumin
matrix T = r(table)
canon e1.mi_noy.b_albumin.stata T[1,7]
canon e1.mi_noy.b_albumin.lo.stata T[5,7]
canon e1.mi_noy.b_albumin.hi.stata T[6,7]
matrix b_noy = e(b)
* apparent development AUROC of the pooled model, averaged over the m completed development sets
mi_devauc e1.mi_noy.dev_auroc.stata
scalar auc_dev_noy = sc_auc
use `dev', clear
mi set wide
mi register imputed albumin
mi register regular delirium age_c female frail dementia asa3 anticoag
* imputation model WITH the outcome
mi impute chained (pmm, knn(10)) albumin = age_c female frail dementia asa3 anticoag delirium, add($MI_M) rseed(202610)
mi estimate, post: logit delirium age_c female frail dementia asa3 anticoag albumin
matrix T = r(table)
canon e1.mi_y.b_albumin.stata T[1,7]
canon e1.mi_y.b_albumin.lo.stata T[5,7]
canon e1.mi_y.b_albumin.hi.stata T[6,7]
matrix b_y = e(b)
* apparent development AUROC of the pooled model, averaged over the m completed development sets
mi_devauc e1.mi_y.dev_auroc.stata
scalar auc_dev_y = sc_auc
* discrimination and calibration of each fitted model in the simulated validation file (albumin fully observed)
import delimited using ../../datasets/W1/W1_validation.csv, clear varnames(1) asdouble
generate double age_c = age - 80
matrix score double lp_cc = b_cc
matrix score double lp_noy = b_noy
matrix score double lp_y = b_y
roctab delirium lp_cc
scalar auc_val_cc = r(area)
canon e1.cc.auroc scalar(auc_val_cc)
roctab delirium lp_noy
scalar auc_val_noy = r(area)
canon e1.mi_noy.auroc.stata scalar(auc_val_noy)
roctab delirium lp_y
scalar auc_val_y = r(area)
canon e1.mi_y.auroc.stata scalar(auc_val_y)
* AUROC table: development rows (apparent) and the validation file, one row per fitted model
matrix AUC = (scalar(auc_dev_cc), scalar(auc_val_cc) \ scalar(auc_dev_noy), scalar(auc_val_noy) \ scalar(auc_dev_y), scalar(auc_val_y))
matrix rownames AUC = cc mi_noy mi_y
matrix colnames AUC = development validation
matrix list AUC, format(%9.4f) title(AUROC of each fitted model, simulated data)
* calibration slope and calibration-in-the-large of each fitted model, normal 95% CIs
calprint e1.cc.cal lp_cc
calprint e1.mi_noy.cal lp_noy .stata
calprint e1.mi_y.cal lp_y .stata
* validation with missing albumin: the validation file has albumin fully observed, so a reproducible MAR mask
* is applied in memory (the file is not changed). The mask uses the missingness model the registry was
* simulated with and a deterministic uniform u = frac(id x 0.6180339887498949), identical in R.
generate double u_mask = mod(id*0.6180339887498949, 1)
generate double p_mask = invlogit(-1.57 + 0.9*dementia + 0.5*asa3 + 0.02*age_c + 0.3*frail)
generate double alb_m = albumin
replace alb_m = . if u_mask < p_mask
quietly count if missing(alb_m)
* validation rows whose albumin is masked
canonn e1.val.n_masked r(N)
* one fixed model is scored: the complete-case fit; albumin fully observed first (same as e1.cc.auroc)
lpcc lpv_full albumin
roctab delirium lpv_full
scalar auc_m_full = r(area)
canon e1.val.auroc_full scalar(auc_m_full)
calprint e1.val.cal_full lpv_full
* Y-free regression imputation fitted on the development data (deployment-style)
matrix score double alb_hat = b_ri
generate double alb_ri = cond(missing(alb_m), alb_hat, alb_m)
lpcc lpv_ri alb_ri
roctab delirium lpv_ri
scalar auc_m_regimp = r(area)
canon e1.val.auroc_regimp scalar(auc_m_regimp)
* single imputation: these calibration CIs ignore the uncertainty of the filled values
calprint e1.val.cal_regimp lpv_ri
* multiple imputation inside the validation sample, with versus without the validation outcomes
keep id delirium age_c female frail dementia asa3 anticoag alb_m
tempfile valm
quietly save `valm'
foreach wy in with without {
use `valm', clear
local yv = cond("`wy'" == "with", "delirium", "")
mi set wide
mi register imputed alb_m
mi register regular delirium age_c female frail dementia asa3 anticoag
mi impute chained (pmm, knn(10)) alb_m = age_c female frail dementia asa3 anticoag `yv', add($MI_M) rseed(202611)
scalar sc_auc = 0
matrix CALM = J($MI_M, 4, .)
forvalues m = 1/$MI_M {
quietly lpcc lpm _`m'_alb_m
quietly roctab delirium lpm
scalar sc_auc = scalar(sc_auc) + r(area)/$MI_M
calfit lpm
matrix CALM[`m', 1] = r(slope)
matrix CALM[`m', 2] = r(slope_se)
matrix CALM[`m', 3] = r(citl)
matrix CALM[`m', 4] = r(citl_se)
drop lpm
}
* validation AUROC averaged over the m imputations, validation outcomes `wy' in the imputation model
canon e1.val.auroc_mi_`wy'_y.stata scalar(sc_auc)
scalar auc_m_`wy' = sc_auc
* slope and CITL pooled over the m imputations with Rubin's rules (t-based 95% CI)
mata: rubin_cal("CALM")
calcanon e1.val.cal_mi_`wy'_y .stata
}
* AUROC of the fixed complete-case model after the masked validation albumin was filled four ways
matrix AUCM = (scalar(auc_m_full) \ scalar(auc_m_regimp) \ scalar(auc_m_with) \ scalar(auc_m_without))
matrix rownames AUCM = full regimp mi_y mi_noy
matrix colnames AUCM = auroc
matrix list AUCM, format(%9.4f) title(AUROC after filling masked validation albumin, simulated data)
* calibration table: the three fitted models on the validation file, then the fixed complete-case model
* after the masked validation albumin was filled four ways (slope 1 and CITL 0 mean perfect calibration);
* val_mi_y and val_mi_noy: multiple imputation in the validation file with and without the validation outcomes
matrix rownames CAL = cc mi_noy mi_y val_full val_regimp val_mi_y val_mi_noy
matrix colnames CAL = slope slope_lo slope_hi citl citl_lo citl_hi
matrix list CAL, format(%9.4f) title(Calibration on the validation set, simulated data)
* the reference value for the albumin coefficient: this working model fitted to a very large simulated
* population with every albumin value recorded, and that reference model's AUROC on the validation file
truthat pseudo_true_coefficients albumin pseudo_true_albumin
truthecho auroc_pseudo_true_on_validation
* ---- nested trial: sampling-score weights to move the trial result to a target population ----
use `full', clear
logit trial age_c female frail dementia asa3 anticoag, tolerance(1e-10) ltolerance(1e-12) nrtolerance(1e-10)
predict double s_hat, pr
* target: the whole registry
generate double w_ipsw = 1/s_hat if trial == 1
* target: the non-participants (inverse odds of participation)
generate double w_iosw = (1 - s_hat)/s_hat if trial == 1
regress delirium surg24 if trial == 1, vce(robust)
ciz a2.trial.rd surg24 t
* robust SE of the unweighted trial difference
scalar sc_setr = _se[surg24]
canon a2.trial.rd.se scalar(sc_setr)
quietly regress delirium surg24 [pw = w_ipsw] if trial == 1, vce(robust)
regress
ciz a2.ipsw.rd surg24 t
* robust SE after weighting to the whole registry, and the variance cost: IPSW SE over the trial SE
canon a2.ipsw.rd.se _se[surg24]
canon a2.ipsw.se_ratio _se[surg24]/scalar(sc_setr)
quietly regress delirium surg24 [pw = w_iosw] if trial == 1, vce(robust)
regress
ciz a2.iosw.rd surg24 t
* robust SE after weighting to the non-participants, and its ratio to the trial SE
canon a2.iosw.rd.se _se[surg24]
canon a2.iosw.se_ratio _se[surg24]/scalar(sc_setr)
quietly count if trial == 1
scalar sc_ntr = r(N)
summarize w_ipsw, meanonly
canon a2.ipsw.max r(max)
essprint w_ipsw if trial == 1, key(a2.ipsw.ess)
* ESS as a share of the 950 trial participants
canon a2.ipsw.ess_frac scalar(sc_ess)/scalar(sc_ntr)
summarize w_iosw, meanonly
canon a2.iosw.max r(max)
essprint w_iosw if trial == 1, key(a2.iosw.ess)
canon a2.iosw.ess_frac scalar(sc_ess)/scalar(sc_ntr)
* risk ratios beside the transported risk differences (log-link Poisson, robust SE, weights known)
generate double w_one = 1
wpoisrr w_one if trial == 1, key(a2.trial.rr)
wpoisrr w_ipsw if trial == 1, key(a2.ipsw.rr)
wpoisrr w_iosw if trial == 1, key(a2.iosw.rr)
* their targets: trial participants, the whole registry population, the non-participants (trial = 0)
truthecho del_rd_trial_participants del_rr_trial_participants del_rd_obs_part_trial0 del_rr_obs_part_trial0
display "All numbers above are from simulated data (ข้อมูลจำลอง)."
. * ---- nested trial: sampling-score weights to move the trial result to a target population ----
. use `full', clear
. logit trial age_c female frail dementia asa3 anticoag, tolerance(1e-10) ltolerance(1e-12) nrtolerance(1e-10)
Iteration 0: Log likelihood = -3821.7458
Iteration 1: Log likelihood = -3451.1611
Iteration 2: Log likelihood = -3354.8237
Iteration 3: Log likelihood = -3350.7977
Iteration 4: Log likelihood = -3350.7525
Iteration 5: Log likelihood = -3350.7525
Iteration 6: Log likelihood = -3350.7525
Logistic regression Number of obs = 20,000
LR chi2(6) = 941.99
Prob > chi2 = 0.0000
Log likelihood = -3350.7525 Pseudo R2 = 0.1232
------------------------------------------------------------------------------
trial | Coefficient Std. err. z P>|z| [95% conf. interval]
-------------+----------------------------------------------------------------
age_c | -.0474933 .0032768 -14.49 0.000 -.0539157 -.0410709
female | .0114397 .0754341 0.15 0.879 -.1364083 .1592878
frail | -.736586 .0957994 -7.69 0.000 -.9243494 -.5488225
dementia | -1.778538 .1827157 -9.73 0.000 -2.136654 -1.420422
asa3 | -.598664 .0729991 -8.20 0.000 -.7417396 -.4555884
anticoag | -.5789111 .1168301 -4.96 0.000 -.807894 -.3499283
_cons | -2.45805 .0781865 -31.44 0.000 -2.611292 -2.304807
------------------------------------------------------------------------------
. predict double s_hat, pr
. * target: the whole registry
. generate double w_ipsw = 1/s_hat if trial == 1
(19,050 missing values generated)
. * target: the non-participants (inverse odds of participation)
. generate double w_iosw = (1 - s_hat)/s_hat if trial == 1
(19,050 missing values generated)
. regress delirium surg24 if trial == 1, vce(robust)
Linear regression Number of obs = 950
F(1, 948) = 7.11
Prob > F = 0.0078
R-squared = 0.0075
Root MSE = .30333
------------------------------------------------------------------------------
| Robust
delirium | Coefficient std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
surg24 | -.0528838 .0198341 -2.67 0.008 -.0918075 -.01396
_cons | .1304348 .0157191 8.30 0.000 .0995866 .161283
------------------------------------------------------------------------------
R: ตารางการถ่ายโอนผลและการเสียชีวิตภายในหนึ่งปี
# Simulated hip-fracture registry of older adults: surgery within 24 hours (surg24), delirium, one-year
# death, a small nested randomised trial (trial = 1) and a delirium risk model with albumin partly missing.
# Simulated data: not evidence about any real drug or patient.
# The simulated registry files (not published) are read from a folder two levels above this script.
set.seed(202610)
# number of imputations m: at least the percentage of incomplete rows (albumin is missing in about 30 percent)
M_IMP <- 40
# ---- packages: check, install into the user library when missing, report ----
need <- c("WeightIt", "cobalt", "survival", "sandwich", "lmtest", "marginaleffects", "mice")
for (p in need) {
if (!requireNamespace(p, quietly = TRUE)) {
install.packages(p, repos = "https://cloud.r-project.org", quiet = TRUE)
}
cat(sprintf("VERIFY %s %s %s\n", p,
if (requireNamespace(p, quietly = TRUE)) "available" else "missing",
if (requireNamespace(p, quietly = TRUE)) as.character(packageVersion(p)) else ""))
}
suppressPackageStartupMessages({
library(WeightIt); library(cobalt); library(survival); library(sandwich)
library(lmtest); library(marginaleffects); library(mice)
})
# ---- helpers: print each result on its own CANON line, rounded to 4 decimals ----
canon <- function(key, x) cat(sprintf("CANON w1.%s %.4f\n", key, x))
canon_n <- function(key, x) cat(sprintf("CANON w1.%s %d\n", key, as.integer(x)))
canon_ci <- function(key, est, lo, hi) {
canon(key, est); canon(paste0(key, ".lo"), lo); canon(paste0(key, ".hi"), hi)
}
z <- qnorm(0.975)
# weighted linear model with robust (HC1) standard errors and t-based CI, as Stata's regress [pw], vce(robust)
# returns estimate, lower, upper, robust SE
wls_rd <- function(f, data, w) {
data$wt_ <- w
fit <- lm(f, data = data, weights = wt_)
V <- vcovHC(fit, type = "HC1")
ci <- coefci(fit, vcov. = V)
c(coef(fit)[2], ci[2, ], sqrt(V[2, 2]))
}
# weighted log-link Poisson model for a risk ratio, robust SE treating the weights as known,
# as Stata's glm [pw], family(poisson) link(log) vce(robust) (sandwich times n/(n-1)); returns RR, lower, upper
wpois_rr <- function(f, data, w) {
data$wt_ <- w
fit <- glm(f, family = quasipoisson(link = "log"), data = data, weights = wt_)
n <- nobs(fit)
b <- coef(fit)[[2]]; se <- sqrt(sandwich(fit)[2, 2] * n / (n - 1))
c(exp(b), exp(b - z * se), exp(b + z * se))
}
ess <- function(w) sum(w)^2 / sum(w^2)
# simulated truth: the values the simulation was built to produce, read from its settings file (not
# published) and printed as w1.truth.<name>
tj <- jsonlite::read_json(file.path("..", "..", "datasets", "W1", "truth.json"))
truth_echo <- function(nm) canon(paste0("truth.", nm), tj$canon_echo[[nm]])
# ---- data: the simulated registry file and the simulated validation file (not published) ----
d <- read.csv(file.path("..", "..", "datasets", "W1", "W1.csv"))
v <- read.csv(file.path("..", "..", "datasets", "W1", "W1_validation.csv"))
d$age_c <- d$age - 80; d$age_c2 <- d$age_c^2; d$fd <- d$frail * d$dementia
v$age_c <- v$age - 80
d0 <- d[d$trial == 0, ] # observational part: treatment chosen by clinicians
canon_n("n", nrow(d)); canon_n("n.trial", sum(d$trial)); canon_n("n.obs", nrow(d0))
canon_n("n.albumin_missing", sum(is.na(d$albumin))); canon_n("n.validation", nrow(v))
# share of registry rows with albumin missing, and the number of imputations m used below
canon("n.albumin_missing_frac", mean(is.na(d$albumin))); canon_n("e1.mi_m", M_IMP)
canon_n("n.obs.treated", sum(d0$surg24)); canon_n("n.obs.died", sum(d0$died)); canon_n("n.obs.lost", sum(d0$lost))
# ---- crude comparison of delirium (observational part) ----
r <- wls_rd(delirium ~ surg24, d0, rep(1, nrow(d0)))
canon_ci("crude.del.rd", r[1], r[2], r[3])
# ---- propensity score models: main effects only versus the true form ----
f_mis <- surg24 ~ age_c + female + frail + dementia + asa3 + anticoag
f_cor <- surg24 ~ age_c + age_c2 + female + frail + dementia + fd + asa3 + anticoag
ps_mis <- fitted(glm(f_mis, family = binomial, data = d0))
ps_cor <- fitted(glm(f_cor, family = binomial, data = d0))
a <- d0$surg24
w_mis <- ifelse(a == 1, 1 / ps_mis, 1 / (1 - ps_mis))
w_ate <- ifelse(a == 1, 1 / ps_cor, 1 / (1 - ps_cor))
w_att <- ifelse(a == 1, 1, ps_cor / (1 - ps_cor))
w_ato <- ifelse(a == 1, 1 - ps_cor, ps_cor)
canon("ps.min", min(ps_cor)); canon("ps.max", max(ps_cor)); canon_n("ps.n_below_005", sum(ps_cor < 0.05))
# ---- standardised mean differences: weighted means, unweighted pooled SD in the denominator ----
vars <- c("age_c", "age_c2", "female", "frail", "dementia", "fd", "asa3", "anticoag")
smd <- function(x, w) {
m1 <- weighted.mean(x[a == 1], w[a == 1]); m0 <- weighted.mean(x[a == 0], w[a == 0])
(m1 - m0) / sqrt((var(x[a == 1]) + var(x[a == 0])) / 2)
}
for (lab in c("raw", "mis", "cor")) {
w <- switch(lab, raw = rep(1, nrow(d0)), mis = w_mis, cor = w_ate)
s <- sapply(vars, function(x) smd(d0[[x]], w))
for (x in vars) canon(sprintf("smd.%s.%s", lab, x), s[[x]])
canon(sprintf("smd.%s.maxabs", lab), max(abs(s)))
}
# the same balance tables as cobalt reports them (display only)
W_mis <- weightit(f_mis, data = d0, method = "glm", estimand = "ATE")
W_cor <- weightit(f_cor, data = d0, method = "glm", estimand = "ATE")
print(bal.tab(W_mis, data = d0, addl = ~ age_c2 + fd, un = TRUE, s.d.denom = "pooled"))
print(bal.tab(W_cor, un = TRUE, s.d.denom = "pooled"))
# ---- weights: raw, stabilised, truncated at the 1st and 99th percentiles ----
pa <- mean(a)
w_sw <- w_ate * ifelse(a == 1, pa, 1 - pa) # stabilised: one constant per arm
q <- quantile(w_ate, c(0.01, 0.99), type = 2) # type 2 matches Stata's _pctile
w_tr <- pmin(pmax(w_ate, q[1]), q[2])
canon("wt.trunc.p01", q[1]); canon("wt.trunc.p99", q[2])
for (lab in c("raw", "sw", "trunc")) {
w <- switch(lab, raw = w_ate, sw = w_sw, trunc = w_tr)
canon(sprintf("wt.%s.max", lab), max(w)); canon(sprintf("wt.%s.ess", lab), ess(w))
canon(sprintf("wt.%s.ess_treated", lab), ess(w[a == 1])); canon(sprintf("wt.%s.ess_control", lab), ess(w[a == 0]))
}
# ---- IPTW effects on delirium ----
# ATE and ATT: weighted outcome model with M-estimation SE that accounts for the estimated score
fit_ate <- lm_weightit(delirium ~ surg24, data = d0, weightit = W_cor)
fit_mis <- lm_weightit(delirium ~ surg24, data = d0, weightit = W_mis)
W_att <- weightit(f_cor, data = d0, method = "glm", estimand = "ATT")
fit_att <- lm_weightit(delirium ~ surg24, data = d0, weightit = W_att)
for (nm in c("ate", "att", "ate_mis")) {
fit <- switch(nm, ate = fit_ate, att = fit_att, ate_mis = fit_mis)
b <- coef(fit)[["surg24"]]; se <- sqrt(vcov(fit)["surg24", "surg24"])
canon_ci(paste0("ipw.del.", nm), b, b - z * se, b + z * se)
# SE of the ATE that accounts for estimating e(X) (M-estimation)
if (nm == "ate") canon("ipw.del.ate.se", se)
}
# ATE risk ratio from the two weighted risks (log link on the weighted means, same M-estimation SE)
fit_rr <- glm_weightit(delirium ~ surg24, data = d0, weightit = W_cor, family = quasipoisson(link = "log"))
b <- coef(fit_rr)[["surg24"]]; se <- sqrt(vcov(fit_rr)["surg24", "surg24"])
canon("ipw.del.ate.risk1", weighted.mean(d0$delirium[a == 1], w_ate[a == 1]))
canon("ipw.del.ate.risk0", weighted.mean(d0$delirium[a == 0], w_ate[a == 0]))
canon_ci("ipw.del.ate.rr", exp(b), exp(b - z * se), exp(b + z * se))
# misspecified score: the same risk ratio and M-estimation SE under the main-effects-only propensity model
fit_rr_mis <- glm_weightit(delirium ~ surg24, data = d0, weightit = W_mis, family = quasipoisson(link = "log"))
b <- coef(fit_rr_mis)[["surg24"]]; se <- sqrt(vcov(fit_rr_mis)["surg24", "surg24"])
canon_ci("ipw.del.ate_mis.rr", exp(b), exp(b - z * se), exp(b + z * se))
# ATO, stabilised, truncated and trimmed: weighted regression, robust SE treating weights as known
# raw ATE weights through the same weights-known estimator, so raw and stabilised compare like for like
r <- wls_rd(delirium ~ surg24, d0, w_ate); canon_ci("ipw.del.ate_rawreg", r[1], r[2], r[3])
canon("ipw.del.ate_rawreg.se", r[4]) # weights-known robust SE, raw weights
r <- wls_rd(delirium ~ surg24, d0, w_ato); canon_ci("ipw.del.ato", r[1], r[2], r[3])
r <- wls_rd(delirium ~ surg24, d0, w_sw); canon_ci("ipw.del.ate_sw", r[1], r[2], r[3])
canon("ipw.del.ate_sw.se", r[4]) # weights-known robust SE, stabilised weights
r <- wls_rd(delirium ~ surg24, d0, w_tr); canon_ci("ipw.del.ate_trunc", r[1], r[2], r[3])
keep <- ps_cor >= 0.1 & ps_cor <= 0.9 # trimming changes the population
canon_n("n.trim", sum(keep))
r <- wls_rd(delirium ~ surg24, d0[keep, ], w_ate[keep]); canon_ci("ipw.del.ate_trim", r[1], r[2], r[3])
# risk ratios beside each weights-known risk difference (log-link Poisson, robust SE, weights known)
r <- wpois_rr(delirium ~ surg24, d0, w_ate); canon_ci("ipw.del.ate_rawreg.rr", r[1], r[2], r[3])
r <- wpois_rr(delirium ~ surg24, d0, w_sw); canon_ci("ipw.del.ate_sw.rr", r[1], r[2], r[3])
r <- wpois_rr(delirium ~ surg24, d0, w_tr); canon_ci("ipw.del.ate_trunc.rr", r[1], r[2], r[3])
r <- wpois_rr(delirium ~ surg24, d0[keep, ], w_ate[keep]); canon_ci("ipw.del.ate_trim.rr", r[1], r[2], r[3])
r <- wpois_rr(delirium ~ surg24, d0, w_ato); canon_ci("ipw.del.ato.rr", r[1], r[2], r[3])
# the true values these delirium estimates are compared with (simulated truth)
for (nm in c("del_rd_ate_trial0", "del_rr_ate_trial0", "del_rd_att_trial0", "del_rr_att_trial0",
"del_rd_ato_trial0", "del_rr_ato_trial0", "del_rd_trimmed_on_true_ps_trial0",
"del_rr_trimmed_on_true_ps_trial0", "del_assoc_trial0_rd", "del_assoc_trial0_rr")) truth_echo(nm)
# ---- 1-year death: crude Kaplan-Meier risk and crude Cox ----
km <- summary(survfit(Surv(fu_months, died) ~ surg24, data = d0), times = 12)
risk <- 1 - km$surv; se_km <- km$std.err # strata order: surg24 = 0, then 1
canon("km.risk0", risk[1]); canon("km.risk1", risk[2])
rd <- risk[2] - risk[1]; se <- sqrt(sum(se_km^2))
canon_ci("crude.death.rd", rd, rd - z * se, rd + z * se)
cx <- coxph(Surv(fu_months, died) ~ surg24, data = d0, ties = "breslow")
b <- coef(cx)[[1]]; se <- sqrt(vcov(cx)[1, 1])
canon_ci("crude.death.hr", exp(b), exp(b - z * se), exp(b + z * se))
# ---- inverse probability of censoring weights: exponential model for loss to follow-up ----
cm <- survreg(Surv(fu_months, lost) ~ dementia + age_c + surg24, data = d0, dist = "exponential")
rate_c <- exp(-predict(cm, type = "lp")) # survreg is on the log-time scale
G <- exp(-rate_c * d0$fu_months) # P(still followed at own end time | X)
obs <- d0$lost == 0 # vital status at 12 months is known
ipcw <- 1 / G
canon("ipcw.max", max(ipcw[obs]))
r <- wls_rd(died ~ surg24, d0[obs, ], rep(1, sum(obs))); canon_ci("naive.death.rd", r[1], r[2], r[3])
r <- wls_rd(died ~ surg24, d0[obs, ], ipcw[obs]); canon_ci("ipcw.death.rd", r[1], r[2], r[3])
# truth for the IPCW-only contrast: censoring-free ASSOCIATIONAL risks by arm in trial = 0 (no confounding control)
for (nm in c("death_assoc_trial0_risk1", "death_assoc_trial0_risk0", "death_assoc_trial0_rd",
"death_assoc_trial0_rr")) truth_echo(nm)
# truth for the IPTW x IPCW contrasts below: the causal 1-year risk difference and risk ratio (ATE, trial = 0)
for (nm in c("death_rd_ate_trial0", "death_rr_ate_trial0")) truth_echo(nm)
# ---- IPTW times IPCW: the 1-year risk difference for ATE, ATT and ATO ----
for (nm in c("ate", "att", "ato")) {
w <- switch(nm, ate = w_ate, att = w_att, ato = w_ato) * ipcw
r <- wls_rd(died ~ surg24, d0[obs, ], w[obs]); canon_ci(paste0("ipw.death.", nm), r[1], r[2], r[3])
}
# ---- IPTW Cox model (ATE weights, robust SE) ----
cw <- coxph(Surv(fu_months, died) ~ surg24, data = d0, weights = w_ate, robust = TRUE, ties = "breslow")
b <- coef(cw)[[1]]; se <- sqrt(vcov(cw)[1, 1])
canon_ci("ipw.death.hr", exp(b), exp(b - z * se), exp(b + z * se))
# ---- link and family: saturated versus adjusted fits (all registry rows) ----
rr_glm <- function(f, fam, data, robust = FALSE, start = NULL) {
fit <- glm(f, family = fam, data = data, start = start)
V <- if (robust) vcovHC(fit, type = "HC0") else vcov(fit)
b <- coef(fit)[["surg24"]]; se <- sqrt(V["surg24", "surg24"])
list(fit = fit, est = exp(b), lo = exp(b - z * se), hi = exp(b + z * se), se = se,
se_naive = sqrt(vcov(fit)["surg24", "surg24"]))
}
# saturated fits (surg24 only): log-binomial, modified Poisson, and Gaussian family with a log link
cb <- rr_glm(delirium ~ surg24, binomial(link = "log"), d)
canon_ci("c2.rr_crude_logbin", cb$est, cb$lo, cb$hi) # model-based SE (identical bread in both languages here)
canon("c2.se_crude_logbin", cb$se) # log-RR SE, log-binomial, model-based
cp <- rr_glm(delirium ~ surg24, poisson(link = "log"), d, TRUE)
canon_ci("c2.rr_crude_poisson", cp$est, cp$lo, cp$hi) # modified Poisson, robust CI
canon("c2.se_crude_poisson_naive", cp$se_naive) # Poisson model-based log-RR SE (too large for a binary outcome)
canon("c2.se_crude_poisson_robust", cp$se) # sandwich log-RR SE
canon("c2.rr_crude_poisson_naive.lo", exp(log(cp$est) - z * cp$se_naive)) # naive Poisson 95% CI, lower
canon("c2.rr_crude_poisson_naive.hi", exp(log(cp$est) + z * cp$se_naive)) # naive Poisson 95% CI, upper
cg <- rr_glm(delirium ~ surg24, gaussian(link = "log"), d, TRUE, start = coef(cp$fit))
canon_ci("c2.rr_crude_gaussian", cg$est, cg$lo, cg$hi) # Gaussian log link, robust CI (saturated: same bread both languages)
# adjusted fits: the log-binomial needs starting values (taken from the Poisson fit) to converge
p2 <- rr_glm(delirium ~ surg24 + age_c + female, poisson(link = "log"), d, TRUE)
l2 <- rr_glm(delirium ~ surg24 + age_c + female, binomial(link = "log"), d, start = coef(p2$fit))
canon("c2.rr_adj2_logbin", l2$est)
# R's glm uses the expected-information SE for the non-canonical log link (Stata's ML glm: observed)
canon("c2.rr_adj2_logbin.lo.r", l2$lo); canon("c2.rr_adj2_logbin.hi.r", l2$hi)
canon("c2.se_adj2_logbin.r", l2$se) # log-RR SE, adjusted log-binomial (expected information)
canon("c2.rr_adj2_poisson", p2$est)
canon("c2.rr_adj2_poisson.lo", p2$lo); canon("c2.rr_adj2_poisson.hi", p2$hi) # modified Poisson, robust CI
canon("c2.se_adj2_poisson_naive", p2$se_naive) # Poisson model-based log-RR SE, adjusted
canon("c2.se_adj2_poisson_robust", p2$se) # sandwich log-RR SE, adjusted
g2 <- rr_glm(delirium ~ surg24 + age_c + female, gaussian(link = "log"), d, TRUE, start = coef(p2$fit))
canon("c2.rr_adj2_gaussian", g2$est) # Gaussian log link, adjusted for age and sex
canon("c2.rr_adj2_gaussian.lo.r", g2$lo); canon("c2.rr_adj2_gaussian.hi.r", g2$hi) # expected-information bread
# adjusted for age and frailty: the Poisson fit gives fitted risks above 1, so the log-binomial cannot start
paf <- rr_glm(delirium ~ surg24 + age_c + frail, poisson(link = "log"), d, TRUE)
canon("c2.rr_adjaf_poisson", paf$est)
canon("c2.rr_adjaf_poisson.lo", paf$lo); canon("c2.rr_adjaf_poisson.hi", paf$hi)
canon("c2.se_adjaf_poisson_naive", paf$se_naive) # Poisson model-based log-RR SE, age and frailty
canon("c2.se_adjaf_poisson_robust", paf$se) # sandwich log-RR SE, age and frailty
canon("c2.adjaf_poisson_max_fitted", max(fitted(paf$fit))) # largest fitted risk: above 1 means the log-binomial wall
lbaf <- tryCatch(glm(delirium ~ surg24 + age_c + frail, family = binomial(link = "log"), data = d, start = coef(paf$fit)),
error = function(e) { cat("NOTE log-binomial (age, frailty) stopped:", conditionMessage(e), "\n"); NULL })
canon_n("c2.adjaf_logbin_converged.r", !is.null(lbaf) && isTRUE(lbaf$converged)) # 1 = converged
# ---- the risk-ratio ladder for a common outcome (all registry rows, same covariates) ----
f_out <- delirium ~ surg24 + age_c + female + frail + dementia + asa3 + anticoag
# rung 1: log-binomial; it stops when a covariate pattern would need a risk above 1
lb <- tryCatch(glm(f_out, family = binomial(link = "log"), data = d),
error = function(e) { cat("NOTE log-binomial stopped:", conditionMessage(e), "\n"); NULL })
canon_n("c3.logbin_converged", !is.null(lb) && isTRUE(lb$converged))
# rung 2: modified Poisson with robust SE
mp <- rr_glm(f_out, poisson(link = "log"), d, TRUE)
canon_ci("c3.rr_poisson", mp$est, mp$lo, mp$hi)
canon("c3.poisson_max_fitted", max(fitted(mp$fit)))
canon_n("c3.poisson_n_fitted_above1", sum(fitted(mp$fit) > 1))
# rung 3: Gaussian family with a LOG link and robust SE (starts from the Poisson fit)
gl <- rr_glm(f_out, gaussian(link = "log"), d, TRUE, start = coef(mp$fit))
# R's sandwich uses the expected-information bread, so this robust CI differs from Stata's ML glm
canon("c3.rr_gaussian", gl$est); canon("c3.rr_gaussian.lo.r", gl$lo); canon("c3.rr_gaussian.hi.r", gl$hi)
# rung 4: logistic model, then marginal standardisation for the risk ratio and risk difference
lg <- glm(f_out, family = binomial, data = d)
b <- coef(lg)[["surg24"]]; se <- sqrt(vcov(lg)["surg24", "surg24"])
canon_ci("c5.or_cond", exp(b), exp(b - z * se), exp(b + z * se))
std <- function(cmp) avg_comparisons(lg, variables = list(surg24 = c(0, 1)), comparison = cmp)
rr <- std("lnratioavg"); canon_ci("c3.rr_std", exp(rr$estimate), exp(rr$conf.low), exp(rr$conf.high))
rd <- std("differenceavg"); canon_ci("c3.rd_std", rd$estimate, rd$conf.low, rd$conf.high)
# the same model averaged over the cohort gives the marginal odds ratio (non-collapsibility)
om <- std("lnoravg"); canon_ci("c5.or_marg", exp(om$estimate), exp(om$conf.low), exp(om$conf.high))
# the realised nested trial (n = 950): one noisy draw. Chance covariate imbalance in a trial this small can
# outweigh non-collapsibility, so these keys are named "realised"; the large-sample pair is printed further down
tr <- d[d$trial == 1, ]
for (nm in c("crude", "adj")) {
f <- if (nm == "crude") delirium ~ surg24 else f_out
ft <- glm(f, family = binomial, data = tr)
b <- coef(ft)[["surg24"]]; se <- sqrt(vcov(ft)["surg24", "surg24"])
canon_ci(paste0("c5.trial_realised.or_", nm), exp(b), exp(b - z * se), exp(b + z * se))
}
# trial-standardised marginal OR: the adjusted trial model averaged over the trial participants
ltr <- glm(f_out, family = binomial, data = tr)
om_t <- avg_comparisons(ltr, variables = list(surg24 = c(0, 1)), comparison = "lnoravg")
canon_ci("c5.trial_realised.or_std", exp(om_t$estimate), exp(om_t$conf.low), exp(om_t$conf.high))
# large-sample simulated truth: conditional OR within frailty strata versus marginal ORs
# in trial participants, and the large-sample value of the six-covariate adjusted model in the trial
for (nm in c("c5_or_cond_nonfrail", "c5_or_cond_frail", "c5_or_marg_trial_nonfrail", "c5_or_marg_trial_frail",
"c5_or_marg_trial", "c5_or_adj_pseudo_trial", "del_rr_whole_population", "del_rd_whole_population"))
truth_echo(nm)
# ---- prediction model for delirium with albumin missing: complete case versus MI without and with Y ----
f_pred <- delirium ~ age_c + female + frail + dementia + asa3 + anticoag + albumin
auc <- function(y, s) {
rk <- rank(s); n1 <- as.numeric(sum(y == 1)); n0 <- as.numeric(sum(y == 0))
(sum(rk[y == 1]) - n1 * (n1 + 1) / 2) / (n1 * n0)
}
val_auc <- function(b) auc(v$delirium, as.vector(model.matrix(f_pred, v) %*% b))
# calibration of a linear predictor lp on the outcome y: the slope is the coefficient of lp in a logistic
# regression of y on lp; calibration-in-the-large (CITL) is the intercept when lp enters as an offset
# (slope fixed at 1). Ideal values: slope 1, CITL 0.
cal_fit <- function(y, lp) {
fs <- glm(y ~ lp, family = binomial)
fc <- glm(y ~ 1, offset = lp, family = binomial)
c(slope = coef(fs)[[2]], slope_se = sqrt(vcov(fs)[2, 2]), citl = coef(fc)[[1]], citl_se = sqrt(vcov(fc)[1, 1]))
}
# slope and CITL with normal 95% CIs: (slope, lo, hi, CITL, lo, hi)
cal_normal <- function(y, lp) {
r <- cal_fit(y, lp)
c(r[["slope"]] + c(0, -z, z) * r[["slope_se"]], r[["citl"]] + c(0, -z, z) * r[["citl_se"]])
}
# Rubin's rules over the m rows of (slope, SE, CITL, SE): pooled estimate, total variance W + (1 + 1/m) B,
# and a t-based 95% CI with the large-sample Rubin df; returns (slope, lo, hi, CITL, lo, hi)
rubin_cal <- function(X) {
M <- nrow(X); out <- numeric(0)
for (j in c(1, 3)) {
q <- mean(X[, j]); w <- mean(X[, j + 1]^2); b <- var(X[, j])
tv <- w + (1 + 1 / M) * b
df <- (M - 1) * (1 + w / ((1 + 1 / M) * b))^2
cq <- qt(0.975, df)
out <- c(out, q, q - cq * sqrt(tv), q + cq * sqrt(tv))
}
out
}
# print slope and CITL as <key>.slope, <key>.citl (and .lo, .hi) and keep a row for the table
cal_tab <- NULL
cal_print <- function(key, row, sfx = "") {
nm <- c("slope", "slope.lo", "slope.hi", "citl", "citl.lo", "citl.hi")
for (k in seq_along(nm)) canon(paste0(key, ".", nm[k], sfx), row[k])
cal_tab <<- rbind(cal_tab, setNames(row, c("slope", "slope_lo", "slope_hi", "citl", "citl_lo", "citl_hi")))
}
cc <- glm(f_pred, family = binomial, data = d) # glm drops rows with albumin missing
canon_n("e1.cc.n", nobs(cc))
b <- coef(cc)[["albumin"]]; se <- sqrt(vcov(cc)["albumin", "albumin"])
canon_ci("e1.cc.b_albumin", b, b - z * se, b + z * se)
print(round(coef(summary(cc)), 4)) # complete-case coefficient table
auc_val <- c(cc = val_auc(coef(cc)))
auc_dev <- c(cc = auc(cc$y, cc$linear.predictors)) # apparent AUROC on the complete-case development rows
canon("e1.cc.auroc", auc_val[["cc"]])
canon("e1.cc.dev_auroc", auc_dev[["cc"]])
xvars <- c("age_c", "female", "frail", "dementia", "asa3", "anticoag")
mi_fit <- function(with_y) {
cols <- c(xvars, "albumin", "delirium")
dd <- d[, cols]
pm <- make.predictorMatrix(dd); pm[, ] <- 0
pm["albumin", xvars] <- 1
if (with_y) pm["albumin", "delirium"] <- 1
imp <- mice(dd, m = M_IMP, method = "pmm", donors = 10, predictorMatrix = pm, seed = 202610, printFlag = FALSE)
s <- summary(pool(with(imp, glm(delirium ~ age_c + female + frail + dementia + asa3 + anticoag + albumin,
family = binomial))), conf.int = TRUE)
list(s = s, imp = imp)
}
lp_val <- list(cc = as.vector(model.matrix(f_pred, v) %*% coef(cc)))
for (nm in c("mi_noy", "mi_y")) {
res <- mi_fit(nm == "mi_y")
s <- res$s
cat(sprintf("Pooled logistic model, imputation %s the outcome (m = %d)\n", if (nm == "mi_y") "with" else "without", M_IMP))
print(data.frame(term = s$term, estimate = round(s$estimate, 4), se = round(s$std.error, 4),
lo = round(s$`2.5 %`, 4), hi = round(s$`97.5 %`, 4)))
bb <- setNames(s$estimate, as.character(s$term))
i <- which(s$term == "albumin")
canon(sprintf("e1.%s.b_albumin.r", nm), s$estimate[i])
canon(sprintf("e1.%s.b_albumin.lo.r", nm), s$`2.5 %`[i]); canon(sprintf("e1.%s.b_albumin.hi.r", nm), s$`97.5 %`[i])
bv <- bb[colnames(model.matrix(f_pred, v))]
auc_val[[nm]] <- val_auc(bv)
canon(sprintf("e1.%s.auroc.r", nm), auc_val[[nm]])
lp_val[[nm]] <- as.vector(model.matrix(f_pred, v) %*% bv)
# apparent development AUROC of the pooled model, averaged over the m completed development sets
dev_auc <- mean(sapply(seq_len(M_IMP), function(m) {
dm <- complete(res$imp, m)
auc(dm$delirium, as.vector(model.matrix(f_pred, dm) %*% bv))
}))
auc_dev[[nm]] <- dev_auc
canon(sprintf("e1.%s.dev_auroc.r", nm), dev_auc)
}
cat("AUROC of each fitted model: development rows (apparent) and validation set, simulated data\n")
print(round(cbind(development = auc_dev, validation = auc_val), 4))
# calibration slope and calibration-in-the-large of each fitted model on the validation file, normal 95% CIs
cal_print("e1.cc.cal", cal_normal(v$delirium, lp_val$cc))
cal_print("e1.mi_noy.cal", cal_normal(v$delirium, lp_val$mi_noy), ".r")
cal_print("e1.mi_y.cal", cal_normal(v$delirium, lp_val$mi_y), ".r")
# validation with missing albumin: the validation file has albumin fully observed, so a reproducible MAR mask
# is applied in memory (the file is not changed). The mask uses the missingness model the registry was
# simulated with and a deterministic uniform u = frac(id x 0.6180339887498949), identical in Stata and R.
pmiss <- tj$parameters$albumin_missing
u <- (v$id * 0.6180339887498949) %% 1
p_m <- plogis(pmiss$intercept + pmiss$dementia * v$dementia + pmiss$asa3 * v$asa3 + pmiss$age_c * v$age_c +
pmiss$frail * v$frail)
vm <- v
vm$albumin[u < p_m] <- NA
canon_n("e1.val.n_masked", sum(is.na(vm$albumin))) # validation rows whose albumin is masked
b_cc <- coef(cc) # one fixed model is scored: the complete-case fit
lp_of <- function(dd) as.vector(model.matrix(f_pred, dd) %*% b_cc)
auc_mask <- c(full = auc(v$delirium, lp_of(v))) # albumin fully observed (same as e1.cc.auroc)
canon("e1.val.auroc_full", auc_mask[["full"]])
cal_print("e1.val.cal_full", cal_normal(v$delirium, lp_of(v)))
# deployment-style single regression imputation fitted on the development data, no outcome anywhere
ri <- lm(albumin ~ age_c + female + frail + dementia + asa3 + anticoag, data = d)
vr <- vm
vr$albumin[is.na(vr$albumin)] <- predict(ri, newdata = vr[is.na(vr$albumin), ])
auc_mask[["regimp"]] <- auc(vr$delirium, lp_of(vr)) # Y-free regression imputation from development data
canon("e1.val.auroc_regimp", auc_mask[["regimp"]])
# single imputation: these calibration CIs ignore the uncertainty of the filled values
cal_print("e1.val.cal_regimp", cal_normal(vr$delirium, lp_of(vr)))
# multiple imputation inside the validation sample, with versus without the validation outcomes:
# AUROC averaged over the m imputations, slope and CITL pooled with Rubin's rules
val_mi <- function(with_y) {
dd <- vm[, c(xvars, "albumin", "delirium")]
pm <- make.predictorMatrix(dd); pm[, ] <- 0
pm["albumin", xvars] <- 1
if (with_y) pm["albumin", "delirium"] <- 1
imp <- mice(dd, m = M_IMP, method = "pmm", donors = 10, predictorMatrix = pm, seed = 202611, printFlag = FALSE)
per <- t(sapply(seq_len(M_IMP), function(m) {
dm <- complete(imp, m); lp <- lp_of(dm)
c(auc = auc(dm$delirium, lp), cal_fit(dm$delirium, lp))
}))
list(auc = mean(per[, "auc"]), cal = rubin_cal(per[, c("slope", "slope_se", "citl", "citl_se")]))
}
for (wy in c("with", "without")) {
r <- val_mi(wy == "with")
auc_mask[[if (wy == "with") "mi_y" else "mi_noy"]] <- r$auc
canon(sprintf("e1.val.auroc_mi_%s_y.r", wy), r$auc) # validation albumin imputed with / without the outcomes
cal_print(sprintf("e1.val.cal_mi_%s_y", wy), r$cal, ".r")
}
# calibration table: the three fitted models on the validation file, then the fixed complete-case model
# after the masked validation albumin was filled four ways (slope 1 and CITL 0 mean perfect calibration);
# val_mi_y and val_mi_noy: multiple imputation in the validation file with and without the validation outcomes
rownames(cal_tab) <- c("cc", "mi_noy", "mi_y", "val_full", "val_regimp", "val_mi_y", "val_mi_noy")
cat("AUROC of the complete-case model after the masked validation albumin was filled four ways, simulated data\n")
print(round(auc_mask, 4))
cat("Calibration on the validation set, simulated data\n")
print(round(cal_tab, 4))
# the reference value for the albumin coefficient: this working model fitted to a very large simulated
# population with every albumin value recorded, and that reference model's AUROC on the validation file
canon("truth.pseudo_true_albumin", tj$prediction_model_delirium$pseudo_true_coefficients$albumin)
canon("truth.auroc_pseudo_true_on_validation", tj$prediction_model_delirium$auroc_pseudo_true_on_validation)
# ---- nested trial: sampling-score weights to move the trial result to a target population ----
sm <- glm(trial ~ age_c + female + frail + dementia + asa3 + anticoag, family = binomial, data = d)
s_hat <- fitted(sm)[d$trial == 1]
w_ipsw <- 1 / s_hat # target: the whole registry
w_iosw <- (1 - s_hat) / s_hat # target: the non-participants (inverse odds)
r <- wls_rd(delirium ~ surg24, tr, rep(1, nrow(tr))); canon_ci("a2.trial.rd", r[1], r[2], r[3])
se_trial <- r[4]; canon("a2.trial.rd.se", se_trial) # robust SE of the unweighted trial difference
r <- wls_rd(delirium ~ surg24, tr, w_ipsw); canon_ci("a2.ipsw.rd", r[1], r[2], r[3])
canon("a2.ipsw.rd.se", r[4]) # robust SE after weighting to the whole registry
canon("a2.ipsw.se_ratio", r[4] / se_trial) # variance cost: IPSW SE over the trial SE
r <- wls_rd(delirium ~ surg24, tr, w_iosw); canon_ci("a2.iosw.rd", r[1], r[2], r[3])
canon("a2.iosw.rd.se", r[4]) # robust SE after weighting to the non-participants
canon("a2.iosw.se_ratio", r[4] / se_trial) # variance cost: inverse-odds SE over the trial SE
canon("a2.ipsw.max", max(w_ipsw)); canon("a2.ipsw.ess", ess(w_ipsw))
canon("a2.iosw.max", max(w_iosw)); canon("a2.iosw.ess", ess(w_iosw))
canon("a2.ipsw.ess_frac", ess(w_ipsw) / nrow(tr)) # ESS as a share of the 950 trial participants
canon("a2.iosw.ess_frac", ess(w_iosw) / nrow(tr))
# risk ratios beside the transported risk differences (log-link Poisson, robust SE, weights known)
r <- wpois_rr(delirium ~ surg24, tr, rep(1, nrow(tr))); canon_ci("a2.trial.rr", r[1], r[2], r[3])
r <- wpois_rr(delirium ~ surg24, tr, w_ipsw); canon_ci("a2.ipsw.rr", r[1], r[2], r[3])
r <- wpois_rr(delirium ~ surg24, tr, w_iosw); canon_ci("a2.iosw.rr", r[1], r[2], r[3])
# their targets: trial participants, the whole registry population, the non-participants (trial = 0)
for (nm in c("del_rd_trial_participants", "del_rr_trial_participants", "del_rd_obs_part_trial0",
"del_rr_obs_part_trial0")) truth_echo(nm)
cat("All numbers above are from simulated data (ข้อมูลจำลอง).\n")
# ---- weighting comparisons: crude risks, simulated truth and registry facts (observational part) ----
# crude delirium risk in each arm and the crude risk ratio (log-link Poisson, robust SE)
canon("crude.del.risk1", mean(d0$delirium[a == 1])); canon("crude.del.risk0", mean(d0$delirium[a == 0]))
r <- wpois_rr(delirium ~ surg24, d0, rep(1, nrow(d0))); canon_ci("crude.del.rr", r[1], r[2], r[3])
# simulated truth: delirium risk by the arm actually received, with no confounding control
for (nm in c("del_assoc_trial0_risk1", "del_assoc_trial0_risk0")) truth_echo(nm)
# simulated truth: delirium risk if every patient had early surgery (risk1) or later surgery (risk0)
canon("truth.del_risk1_ate_trial0", tj$true_delirium$ate_trial0$risk1)
canon("truth.del_risk0_ate_trial0", tj$true_delirium$ate_trial0$risk0)
# the TRUE propensity score of each patient (the form the simulation used, with age squared and
# frailty x dementia): its range and the share of patients below 0.05
tp <- tj$parameters$ps
e_true <- plogis(tp$intercept + tp$age_c * d0$age_c + tp$age_c_sq * d0$age_c2 + tp$frail * d0$frail +
tp$dementia * d0$dementia + tp$frail_x_dementia * d0$fd + tp$asa3 * d0$asa3 +
tp$anticoag * d0$anticoag + tp$female * d0$female)
canon("truth.ps_min_trial0", min(e_true)); canon("truth.ps_max_trial0", max(e_true))
canon("truth.ps_below_005_trial0", mean(e_true < 0.05))
# share operated within 24 hours: the constant that stabilises the early-surgery weights (1 minus it for the rest)
canon("wt.sw.p_treated", pa); canon("wt.sw.p_control", 1 - pa)
# registry facts (all 20,000 rows): age SD, and loss to follow-up with and without dementia
canon("reg.age_sd", sd(d$age))
canon("reg.lost_dementia1", mean(d$lost[d$dementia == 1])); canon("reg.lost_dementia0", mean(d$lost[d$dementia == 0]))
# half-width of each 95% CI in the nested trial: the precision a transported estimate gives up
r <- wls_rd(delirium ~ surg24, tr, rep(1, nrow(tr))); canon("a2.trial.rd.halfwidth", (r[3] - r[2]) / 2)
r <- wls_rd(delirium ~ surg24, tr, w_ipsw); canon("a2.ipsw.rd.halfwidth", (r[3] - r[2]) / 2)
r <- wls_rd(delirium ~ surg24, tr, w_iosw); canon("a2.iosw.rd.halfwidth", (r[3] - r[2]) / 2)
# arm size of the later-surgery group, and the ATT weighted risks (early-surgery arm as observed, later-surgery
# arm reweighted to look like it) beside their simulated truth
canon_n("n.obs.control", sum(a == 0))
canon("ipw.del.att.risk1", mean(d0$delirium[a == 1]))
canon("ipw.del.att.risk0", weighted.mean(d0$delirium[a == 0], w_att[a == 0]))
canon("truth.del_risk1_att_trial0", tj$true_delirium$att_trial0$risk1)
canon("truth.del_risk0_att_trial0", tj$true_delirium$att_trial0$risk0)
# variance ratio, treated over control, from weighted variances (weights scaled to sum to the arm size,
# divisor n - 1): before weighting, after the main-effects model, after the revised model
wvar <- function(x, w) {
w <- w * length(w) / sum(w)
m <- sum(w * x) / sum(w)
sum(w * (x - m)^2) / (length(x) - 1)
}
vr <- function(x, w) wvar(x[a == 1], w[a == 1]) / wvar(x[a == 0], w[a == 0])
for (lab in c("raw", "mis", "cor")) {
w <- switch(lab, raw = rep(1, nrow(d0)), mis = w_mis, cor = w_ate)
for (x in vars) canon(sprintf("vr.%s.%s", lab, x), vr(d0[[x]], w))
}
# overlap: quantiles of the estimated propensity score (revised model) in each arm
qs <- c(min = 0, p1 = 0.01, p5 = 0.05, p50 = 0.5, p95 = 0.95, p99 = 0.99, max = 1)
ps_q <- t(sapply(c(early = 1, later = 0), function(g) quantile(ps_cor[a == g], qs, type = 2)))
colnames(ps_q) <- names(qs)
for (arm in rownames(ps_q)) for (k in names(qs)) canon(sprintf("ps.%s.%s", arm, k), ps_q[arm, k])
# balance table: standardised mean differences (weighted means over the unweighted pooled SD) and variance
# ratios, before weighting, after the main-effects model and after the revised model (age squared, frail x dementia)
one <- rep(1, nrow(d0))
bal <- t(sapply(vars, function(x) c(
smd_before = smd(d0[[x]], one), smd_main = smd(d0[[x]], w_mis), smd_revised = smd(d0[[x]], w_ate),
vr_before = vr(d0[[x]], one), vr_main = vr(d0[[x]], w_mis), vr_revised = vr(d0[[x]], w_ate))))
cat("Covariate balance in the observational part, simulated data\n")
print(round(bal, 3))
# ---- summary tables of the weighting results (simulated data) ----
# target_rd is the value each estimate aims at: the associational difference for the crude comparison and
# the naive or IPCW-only death contrasts, the causal effect for the weighted ones (simulated truth)
te <- tj$canon_echo
mest <- function(fit) { # estimate and 95% CI, M-estimation SE (accounts for e(X))
b <- coef(fit)[["surg24"]]; se <- sqrt(vcov(fit)["surg24", "surg24"])
c(b, b - z * se, b + z * se)
}
# overlap: the estimated propensity score (revised model) by arm
cat("Estimated propensity score by arm, observational part, simulated data\n")
print(round(ps_q, 4))
# delirium: crude, IPTW ATE and IPTW ATT
t_iptw <- rbind(
crude = c(mean(d0$delirium[a == 1]), mean(d0$delirium[a == 0]),
wls_rd(delirium ~ surg24, d0, one)[1:3], te$del_assoc_trial0_rd),
iptw_ate = c(weighted.mean(d0$delirium[a == 1], w_ate[a == 1]), weighted.mean(d0$delirium[a == 0], w_ate[a == 0]),
mest(fit_ate), te$del_rd_ate_trial0),
iptw_att = c(mean(d0$delirium[a == 1]), weighted.mean(d0$delirium[a == 0], w_att[a == 0]),
mest(fit_att), te$del_rd_att_trial0))
colnames(t_iptw) <- c("risk_early", "risk_later", "rd", "rd_lo", "rd_hi", "target_rd")
cat("Delirium, early versus later surgery, observational part, simulated data\n")
print(round(t_iptw, 4))
# extreme weights: the weights themselves, then the risk difference under each choice (robust SE, weights known)
t_w <- t(sapply(list(raw = w_ate, stabilised = w_sw, truncated = w_tr), function(w)
c(max = max(w), ess_early = ess(w[a == 1]), ess_later = ess(w[a == 0]))))
cat("ATE weights: largest weight and effective sample size per arm, simulated data\n")
print(round(t_w, 4))
t_ext <- rbind(
raw = c(wls_rd(delirium ~ surg24, d0, w_ate)[1:3], te$del_rd_ate_trial0),
stabilised = c(wls_rd(delirium ~ surg24, d0, w_sw)[1:3], te$del_rd_ate_trial0),
truncated_p1_p99 = c(wls_rd(delirium ~ surg24, d0, w_tr)[1:3], te$del_rd_ate_trial0),
trimmed_0.1_0.9 = c(wls_rd(delirium ~ surg24, d0[keep, ], w_ate[keep])[1:3], te$del_rd_trimmed_on_true_ps_trial0),
overlap_ato = c(wls_rd(delirium ~ surg24, d0, w_ato)[1:3], te$del_rd_ato_trial0))
colnames(t_ext) <- c("rd", "rd_lo", "rd_hi", "target_rd")
cat("Delirium risk difference by weighting choice, observational part, simulated data\n")
print(round(t_ext, 4))
# moving the nested-trial result to a target population: precision cost of the sampling weights
tr_row <- function(w, target) {
r <- wls_rd(delirium ~ surg24, tr, w)
c(r[1:4], se_ratio = r[[4]] / se_trial, ess = ess(w), max_weight = max(w), target_rd = target)
}
t_tr <- rbind(trial = tr_row(rep(1, nrow(tr)), te$del_rd_trial_participants),
ipsw_whole_registry = tr_row(w_ipsw, te$del_rd_whole_population),
inverse_odds_non_participants = tr_row(w_iosw, te$del_rd_obs_part_trial0))
colnames(t_tr)[1:4] <- c("rd", "rd_lo", "rd_hi", "se")
cat("Nested trial (n = 950): delirium risk difference moved to a target population, simulated data\n")
print(round(t_tr, 4))
# one-year death with loss to follow-up (rows with known vital status at 12 months)
t_cens <- rbind(
naive_complete_case = c(wls_rd(died ~ surg24, d0[obs, ], rep(1, sum(obs)))[1:3], te$death_assoc_trial0_rd),
ipcw = c(wls_rd(died ~ surg24, d0[obs, ], ipcw[obs])[1:3], te$death_assoc_trial0_rd),
iptw_x_ipcw_ate = c(wls_rd(died ~ surg24, d0[obs, ], (w_ate * ipcw)[obs])[1:3], te$death_rd_ate_trial0))
colnames(t_cens) <- c("rd", "rd_lo", "rd_hi", "target_rd")
cat("One-year death, observational part, simulated data\n")
print(round(t_cens, 4))
cat("Simulated data (ข้อมูลจำลอง): not evidence about any real patient.\n")
# ---- definition of early surgery, registry facts, positivity tail and trimming (simulated data) ----
# early surgery (surg24 = 1) means surgery within this many hours of admission
canon_n("design.surgery_window_hours", 24)
# registry facts (all 20,000 rows): mean age and the share with delirium
canon("reg.age_mean", mean(d$age)); canon("reg.delirium_risk", mean(d$delirium))
# loss to follow-up with and without dementia, unrounded from the simulation's settings file (not published)
canon6 <- function(key, x) cat(sprintf("CANON w1.%s %.6f\n", key, x))
canon6("truth.registry.lost_dementia1", tj$registry_summary$lost_dementia1)
canon6("truth.registry.lost_dementia0", tj$registry_summary$lost_dementia0)
# share of the observational part with an estimated propensity score below 0.05 (revised model)
canon("ps.frac_below_005", mean(ps_cor < 0.05))
# trimming to 0.1 <= e(X) <= 0.9: how many patients leave the analysis, and their share
canon_n("n.trim_removed", sum(!keep)); canon("n.trim_removed_frac", mean(!keep))
> cat("Nested trial (n = 950): delirium risk difference moved to a target population, simulated data\n")
Nested trial (n = 950): delirium risk difference moved to a target population, simulated data
> print(round(t_tr, 4))
rd rd_lo rd_hi se se_ratio ess
trial -0.0529 -0.0918 -0.0140 0.0198 1.0000 950.0000
ipsw_whole_registry -0.0531 -0.2207 0.1144 0.0854 4.3054 199.0468
inverse_odds_non_participants -0.0518 -0.2256 0.1220 0.0886 4.4646 183.5473
max_weight target_rd
trial 1.0000 -0.0328
ipsw_whole_registry 695.0629 -0.0381
inverse_odds_non_participants 694.0629 -0.0384
> t_cens <- rbind(naive_complete_case = c(wls_rd(died ~
+ surg24, d0[obs, ], rep(1, sum(obs)))[1:3], te$death_assoc_trial0_rd),
+ ipcw = c(wls_rd(died ~ surg24, d0[obs, ], ipcw[obs])[1:3],
+ te$death_assoc_trial0_rd), iptw_x_ipcw_ate = c(wls_rd(died ~
+ surg24, d0[obs, ], (w_ate * ipcw)[obs])[1:3], te$death_rd_ate_trial0))
> colnames(t_cens) <- c("rd", "rd_lo", "rd_hi", "target_rd")
> cat("One-year death, observational part, simulated data\n")
One-year death, observational part, simulated data
> print(round(t_cens, 4))
rd rd_lo rd_hi target_rd
naive_complete_case -0.1810 -0.1942 -0.1679 -0.1746
ipcw -0.1742 -0.1870 -0.1615 -0.1746
iptw_x_ipcw_ate -0.0352 -0.0601 -0.0102 -0.0378
เมื่อมีมากกว่าหนึ่งแบบพร้อมกัน: คูณน้ำหนักเข้าด้วยกัน
เมื่อการวิเคราะห์มีคนที่หายไปมากกว่าหนึ่งแบบ น้ำหนักจะคูณกัน:
$$w_i = w^A_i \times w^C_i \times w^S_i$$ในที่นี้ $w^A_i$, $w^C_i$ และ $w^S_i$ คือน้ำหนักการรักษา น้ำหนักการเซ็นเซอร์ และน้ำหนักการคัดเลือก เมื่อใช้ $1/s(X)$ เป็นน้ำหนักการคัดเลือก ผลคูณของน้ำหนักเหล่านี้คือหนึ่งส่วนความน่าจะเป็นของเส้นทางทั้งหมด ตั้งแต่เข้าสู่การศึกษาจนถึงการอยู่ในการติดตาม เมื่อแบบจำลองเรียงตามลำดับของเหตุการณ์ ส่วน inverse odds มาแทนปัจจัยนั้นเมื่อเป้าหมายคือผู้ที่ไม่ได้เข้าร่วม แบบจำลองแต่ละตัวจึงอยู่ภายใต้ขั้นตอนก่อนหน้า คือแบบจำลองการรักษาประมาณในกลุ่มที่ถูกคัดเข้าการวิเคราะห์ และแบบจำลองการเซ็นเซอร์ใส่การรักษาที่ได้รับเป็นตัวทำนาย
ไม่จำเป็นต้องใช้ทุกปัจจัยเสมอไป ในการทดลองแบบสุ่ม 1:1 $w^A_i$ เท่ากันสำหรับทุกคน การถ่ายโอนผลการทดลองจึงต้องใช้เพียง $w^S_i$ การวิเคราะห์การเสียชีวิตของทะเบียนไม่มีขั้นตอนการคัดเลือก และใช้ $w^A_i \times w^C_i$ ซึ่งคือแถวสุดท้ายของตารางการเสียชีวิต
น้ำหนักแต่ละแบบสมมติอะไร
น้ำหนักตั้งอยู่บนข้อสมมติด้านล่าง ความแลกเปลี่ยนกันได้ (exchangeability) positivity และแบบจำลองที่ถูกต้องใช้กับกลไกแต่ละแบบแยกจากกัน [6, 8]
- Consistency และการไม่มีการแทรกแซงระหว่างบุคคล (no interference) ผลลัพธ์ที่สังเกตได้ของผู้ป่วยแต่ละคนคือผลลัพธ์ภายใต้การรักษาที่ได้รับจริง และการผ่าตัดเร็วหมายถึงการแทรกแซงแบบเดียวกันในการทดลอง ในทะเบียน และในประชากรเป้าหมายใด ๆ การรักษาของผู้ป่วยคนหนึ่งไม่เปลี่ยนผลลัพธ์ของผู้ป่วยอีกคน
- ความแลกเปลี่ยนกันได้ (exchangeability) การรักษา: ไม่มีตัวกวนที่ไม่ได้วัด เมื่อกำหนด $X$ การเซ็นเซอร์: เมื่อกำหนด $X$ และการรักษา ผู้ป่วยที่สูญหาย ณ เวลาใดก็ตามจะมีความเสี่ยงที่จะเสียชีวิตต่อจากนั้นเท่ากับผู้ป่วยที่คล้ายกันซึ่งยังอยู่ในการติดตาม ณ เวลานั้น การคัดเลือก: เมื่อกำหนด $X$ ผู้เข้าร่วมและประชากรเป้าหมายมีผลของการรักษาเท่ากัน ตัวปรับผล (effect modifier) ทุกตัวที่การกระจายต่างกันระหว่างสองกลุ่ม บนมาตราส่วนของผลที่รายงาน จึงต้องอยู่ใน $X$
- Positivity ทุกรูปแบบของตัวแปรร่วมต้องมีโอกาสที่ไม่เป็นศูนย์ที่จะได้รับการรักษาแต่ละแบบ ที่จะอยู่ในการติดตาม และที่จะเข้าร่วมการทดลอง
- แบบจำลองที่ถูกต้อง แบบจำลองที่ผิดให้น้ำหนักที่ผิด ดังนั้นหลังถ่วงน้ำหนักให้ตรวจว่าตัวแปรร่วมแต่ละตัวมีการกระจายในกลุ่มที่ถ่วงน้ำหนักแล้วเหมือนกับในกลุ่มที่ใช้เปรียบเทียบ และมีการกระจายในการทดลองที่ถ่วงน้ำหนักแล้วเหมือนกับในประชากรเป้าหมาย
ข้อสมมติเรื่องการคัดเลือกเป็นข้อที่ถูกข้ามได้ง่าย แบบจำลองการคัดเลือกที่สร้างจากสิ่งที่ทำนายการเข้าร่วม แทนที่จะสร้างจากสิ่งที่ปรับผล อาจทำให้ตัวแปรที่สมดุลเป็นตัวแปรที่ผิด
ตระกูล IPW เทียบกันทีละแถว
| น้ำหนัก | คนที่หายไป | ความน่าจะเป็นที่สร้างแบบจำลอง | น้ำหนักของผู้ที่ถูกสังเกต | เป้าหมาย | ข้อสมมติสำคัญ |
|---|---|---|---|---|---|
| IPTW | ผู้ป่วยกลุ่มเดียวกัน ภายใต้การรักษาอีกแบบ | $e(X) = P(A = 1 \mid X)$ | $1/e(X)$ หรือ $1/(1 - e(X))$ | ประชากรที่วิเคราะห์ (ATE) | ไม่มีตัวกวนที่ไม่ได้วัด |
| IPCW | ผู้ป่วยที่สูญหายจากการติดตาม | $P(C_i(t) = 0 \mid X_i, A_i)$ | หนึ่งส่วนความน่าจะเป็นนั้น | ประชากรเดียวกัน โดยไม่มีใครสูญหาย | การสูญหายไม่ให้ข้อมูลเกี่ยวกับผลลัพธ์ เมื่อกำหนด $X$ และ $A$ |
| IPSW | ประชากรต้นทางที่ไม่ได้เข้าร่วม | $s(X) = P(S = 1 \mid X)$ | $1/s(X)$ | ประชากรต้นทางทั้งหมด | ตัวปรับผล (effect modifier) อยู่ใน $X$ |
| Inverse odds | ผู้ที่ไม่ได้เข้าร่วม | $s(X)$ | $(1 - s(X))/s(X)$ | ผู้ที่ไม่ได้เข้าร่วม (หรือประชากรภายนอก) | ตัวปรับผล (effect modifier) อยู่ใน $X$ |
ความเข้าใจผิดที่พบบ่อยและวิธีแก้
-
"ถ่วงน้ำหนักด้วยคะแนนการถูกคัดเลือกแล้วผลการทดลองใช้ได้กับทุกคน"
น้ำหนักปรับสมดุลเฉพาะสิ่งที่อยู่ในแบบจำลองการคัดเลือก และไปสู่เป้าหมายเดียวที่น้ำหนักนั้นถูกสร้างมา
วิธีแก้: การถ่วงน้ำหนักด้วยส่วนกลับของ sampling score ทำให้ผู้เข้าร่วมการทดลองคล้ายประชากรเป้าหมายเฉพาะในตัวแปรที่อยู่ในแบบจำลองการคัดเลือก และเฉพาะเมื่อตัวปรับผลอยู่ในตัวแปรเหล่านั้น และทุกรูปแบบของตัวแปรในประชากรเป้าหมายมีโอกาสได้เข้าร่วมการทดลอง จึงต้องระบุประชากรเป้าหมายก่อนเลือกน้ำหนัก
-
"การเซ็นเซอร์เป็นตัวกวน (confounder) จึงต้องปรับ"
การเซ็นเซอร์เกิดหลังการรักษาและซ่อนผลลัพธ์ ไม่ได้ขับเคลื่อนการเลือกการรักษา
วิธีแก้: ถ่วงน้ำหนักผู้ป่วยที่ถูกสังเกตด้วยหนึ่งส่วนความน่าจะเป็นที่ประมาณได้ว่าจะยังอยู่ในการติดตาม เมื่อกำหนดตัวทำนายของการสูญหายที่วัดได้และการรักษาที่ได้รับ
-
"IPCW ทำให้การเปรียบเทียบในทะเบียนเป็นเชิงสาเหตุ"
ในทะเบียนจำลอง IPCW เพียงอย่างเดียวให้ -0.174 ใกล้ความสัมพันธ์ที่ไม่มีการเซ็นเซอร์ที่ -0.175 และห่างจากค่าเชิงสาเหตุ -0.038
วิธีแก้: คูณน้ำหนักการเซ็นเซอร์ด้วยน้ำหนักการรักษา ในทะเบียนนี้ให้ -0.035
-
"ค่าประมาณที่ถ่ายโอนผลแม่นยำเท่ากับการทดลอง"
การถ่ายโอนผลไปสู่ทะเบียนทั้งหมดทำให้ค่าคลาดเคลื่อนมาตรฐานเพิ่มเป็น 4.31 เท่า และเหลือ ESS 199 จาก 950
วิธีแก้: รายงาน ESS น้ำหนักที่ใหญ่ที่สุด และช่วงเชื่อมั่นไว้ข้างค่าประมาณของการทดลองเอง
สิ่งที่ควรทำในการวิเคราะห์ของคุณเอง
- ระบุคนที่หายไปและประชากรเป้าหมายก่อนสร้างแบบจำลองใด ๆ
- สร้างแบบจำลองหนึ่งตัวต่อหนึ่งกลไก: การรักษาบนตัวกวน การติดตามบนตัวทำนายของการสูญหายและการรักษา การเข้าร่วมการทดลองบนตัวปรับผล (effect modifier) ทุกตัวที่สงสัย
- สำหรับน้ำหนักแต่ละแบบ ตรวจความน่าจะเป็นที่ได้จากแบบจำลองที่ต่ำที่สุด น้ำหนักที่ใหญ่ที่สุด และ ESS
- พิจารณาค่าคลาดเคลื่อนมาตรฐานแบบแซนด์วิช (robust) หรือ bootstrap (ทำการวิเคราะห์ทั้งหมดซ้ำในผู้ป่วยที่สุ่มซ้ำจากข้อมูลเดิม โดยประมาณแบบจำลองน้ำหนักทุกตัวใหม่ทุกครั้ง)
- รายงานค่าประมาณที่ถ่วงน้ำหนักแต่ละค่าไว้ข้างค่าที่ไม่ถ่วงน้ำหนัก
อภิธานศัพท์
- IPCW (การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นที่ไม่ถูกเซ็นเซอร์)
- การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นที่จะไม่ถูกเซ็นเซอร์ (inverse probability of censoring weighting): ผู้ป่วยที่ยังอยู่ในการติดตามถูกถ่วงน้ำหนักด้วยหนึ่งส่วนความน่าจะเป็นที่จะยังอยู่ในการติดตาม
- IPSW (การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นในการถูกคัดเลือก)
- การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นในการถูกคัดเลือก (inverse probability of selection weighting): ผู้เข้าร่วมการทดลองถูกถ่วงน้ำหนักด้วยหนึ่งส่วนคะแนนการถูกคัดเลือก ซึ่งนำผลไปสู่ประชากรต้นทางทั้งหมด
- sampling score (คะแนนการถูกคัดเลือกเข้าการศึกษา)
- s(X) = P(S = 1 เมื่อกำหนด X) คือความน่าจะเป็นที่จะอยู่ในการศึกษา เมื่อกำหนดตัวแปรร่วม
- inverse odds of sampling weights
- น้ำหนัก (1 - s(X))/s(X) ที่นำผลการทดลองไปสู่ผู้ที่ไม่ได้เข้าร่วม
- informative censoring
- การสูญหายจากการติดตามที่โอกาสเกิดขึ้นกับความเสี่ยงของผลลัพธ์ ทั้งโดยตรงและผ่านลักษณะต่าง ๆ เช่นภาวะสมองเสื่อม
- generalisability
- ผลลัพธ์ยังเป็นจริงในประชากรที่การศึกษาถูกสุ่มมาหรือไม่
- transportability
- ผลลัพธ์ยังเป็นจริงในประชากรที่การศึกษาไม่ได้สุ่มมาหรือไม่
เอกสารอ้างอิง
- Seaman SR, White IR. Review of inverse probability weighting for dealing with missing data. Stat Methods Med Res. 2013;22(3):278-295. doi:10.1177/0962280210395740 https://doi.org/10.1177/0962280210395740
- Robins JM, Finkelstein DM. Correcting for noncompliance and dependent censoring in an AIDS clinical trial with inverse probability of censoring weighted (IPCW) log-rank tests. Biometrics. 2000;56:779-788. doi:10.1111/j.0006-341x.2000.00779.x https://doi.org/10.1111/j.0006-341x.2000.00779.x
- Hernán MA, Hernandez-Diaz S, Robins JM. A structural approach to selection bias. Epidemiology. 2004;15:615-625. doi:10.1097/01.ede.0000135174.63482.43 https://doi.org/10.1097/01.ede.0000135174.63482.43
- Cole SR, Stuart EA. Generalizing evidence from randomized clinical trials to target populations: the ACTG 320 trial. Am J Epidemiol. 2010;172:107-115. doi:10.1093/aje/kwq084 https://doi.org/10.1093/aje/kwq084
- Stuart EA, Cole SR, Bradshaw CP, Leaf PJ. The use of propensity scores to assess the generalizability of results from randomized trials. J R Stat Soc Ser A Stat Soc. 2011;174(2):369-386. doi:10.1111/j.1467-985x.2010.00673.x https://doi.org/10.1111/j.1467-985x.2010.00673.x
- Dahabreh IJ, Robertson SE, Tchetgen EJ, Stuart EA, Hernán MA. Generalizing causal inferences from individuals in randomized trials to all trial-eligible individuals. Biometrics. 2019;75(2):685-694. doi:10.1111/biom.13009 https://doi.org/10.1111/biom.13009
- Westreich D, Edwards JK, Lesko CR, Stuart E, Cole SR. Transportability of trial results using inverse odds of sampling weights. Am J Epidemiol. 2017;186:1010-1014. doi:10.1093/aje/kwx164 https://doi.org/10.1093/aje/kwx164
- Lesko CR, Buchanan AL, Westreich D, Edwards JK, Hudgens MG, Cole SR. Generalizing study results: a potential outcomes perspective. Epidemiology. 2017;28(4):553-561. doi:10.1097/ede.0000000000000664 https://doi.org/10.1097/ede.0000000000000664
ประเด็นสำคัญ
- การถ่วงน้ำหนักด้วยส่วนกลับของความน่าจะเป็นใช้แทนคนที่ไม่ถูกสังเกต โดยเพิ่มน้ำหนักให้ผู้ที่ถูกสังเกตซึ่งมีโอกาสน้อยที่จะถูกสังเกต
- น้ำหนักการรักษา การเซ็นเซอร์ และการคัดเลือกใช้แม่แบบเดียวกัน แต่ต้องมีแบบจำลอง ข้อสมมติ และประชากรเป้าหมายที่ระบุชื่อของตัวเอง
- น้ำหนักการเซ็นเซอร์ขจัดอคติจากการสูญหายจากการติดตาม รวมถึงการสูญหายแบบ informative ที่อธิบายได้ด้วยตัวทำนายของการสูญหายที่วัดไว้ แต่ไม่ขจัดตัวกวน ในทะเบียนจำลอง IPCW เพียงอย่างเดียวให้ -0.174 เทียบกับค่าเชิงสาเหตุ -0.038
- เมื่อแบบจำลองการคัดเลือกมีตัวปรับผล (effect modifier) ครบ หนึ่งส่วนคะแนนการถูกคัดเลือกนำผลการทดลองไปสู่ประชากรต้นทางทั้งหมด และ inverse odds ถ่ายโอนผลไปสู่ผู้ที่ไม่ได้เข้าร่วม
- น้ำหนักการคัดเลือกถ่ายโอนผลได้เฉพาะผ่านตัวปรับผล (effect modifier) ที่อยู่ในแบบจำลองของมัน โดยแลกกับความแม่นยำที่ลดลงซึ่ง ESS ทำให้มองเห็นได้
อ่านต่อในวิกิ: [[iptw-guide-th]]