ใช้ผลลัพธ์ช่วยเติมค่าตัวทำนายที่หายไป: จำเป็นตอนพัฒนาแบบจำลอง แต่ใช้ไม่ได้ข้างเตียงผู้ป่วย

Clinical Epidemiology ResearchMethodology and Research Design THUniqcret doctor knowledges TH
ใช้ผลลัพธ์ช่วยเติมค่าตัวทำนายที่หายไป: จำเป็นตอนพัฒนาแบบจำลอง แต่ใช้ไม่ได้ข้างเตียงผู้ป่วย
On this page

Read the English version

บทคัดย่อ

ทีมที่พัฒนาคะแนนความเสี่ยงภาวะเพ้อสับสนเฉียบพลัน (delirium) หลังกระดูกสะโพกหักพบว่าค่า albumin ซึ่งเป็นโปรตีนในเลือดหายไป 6,066 รายจากเวชระเบียนจำลอง 20,000 ราย และถามว่าจะให้ delirium ช่วยเติมค่าที่หายไปได้หรือไม่ ในที่นี้ การเติมค่าที่หายไปหลายครั้ง (multiple imputation คือการเติมช่องว่างแต่ละช่องหลายครั้งจากแบบจำลองทำนาย) โดยไม่ใส่ delirium ไว้ในแบบจำลองนั้น ดึงสัมประสิทธิ์ของ albumin ไปที่ -0.0670 การวิเคราะห์เฉพาะผู้ป่วยที่มีค่า albumin ให้ -0.1014 การเติมค่าที่ใส่ delirium ให้ -0.0991 และแบบจำลองเดียวกันที่ปรับกับประชากรจำลองขนาดใหญ่มากให้ -0.0962 ในชุดตรวจสอบความถูกต้อง (validation set) แยกต่างหาก ความสามารถในการจำแนก (discrimination คือการจัดให้ผู้ป่วยที่เกิด delirium มีความเสี่ยงทำนายสูงกว่าผู้ที่ไม่เกิด) แทบไม่เปลี่ยนระหว่างกลยุทธ์ และความสอดคล้องระหว่างความเสี่ยงที่ทำนายกับความเสี่ยงที่สังเกตได้ (calibration) ก็ไม่พบความคลาดเคลื่อนที่ตรวจจับได้ การตรวจทั้งสองแบบจึงไม่เผยให้เห็นสัมประสิทธิ์ที่อ่อนลง บทความนี้ตามแบบจำลองผ่านการพัฒนา การตรวจสอบความถูกต้องภายใน (internal validation คือการทำกระบวนการพัฒนาทั้งหมดซ้ำในตัวอย่างที่สุ่มซ้ำแบบใส่คืน (bootstrap sample) หรือในกลุ่มย่อย (fold) ของการตรวจสอบไขว้ (cross-validation)) การตรวจสอบความถูกต้องภายนอก (external validation คือการทดสอบในตัวอย่างใหม่) และการใช้ข้างเตียง พร้อมโค้ด Stata และ R และผลลัพธ์จริง และระบุในแต่ละขั้นว่าผลลัพธ์ช่วยเติมตัวทำนายที่หายไปได้หรือไม่


ภาพสรุป ข้อมูลจำลอง

คะแนนทำนาย delirium ที่ albumin หายไป 30%

ทีมดูแลผู้ป่วยกระดูกสะโพกหักกำลังสร้างคะแนนที่ทำนาย delirium ซึ่งเป็นภาวะสับสนเฉียบพลันที่พบบ่อยในผู้สูงอายุหลังผ่าตัด คะแนนนี้จะใช้เฉพาะข้อมูลที่มีตอนรับเข้าโรงพยาบาล ระดับ albumin ในซีรัม ซึ่งเป็นโปรตีนในเลือดที่มักต่ำในผู้ป่วยที่เปราะบางและขาดสารอาหาร เป็นตัวทำนายตัวหนึ่งที่เข้าข่าย ในทะเบียนจำลองของทีมที่มีผู้ป่วย 20,000 ราย albumin หายไป 6,066 ราย หรือ 30.3%

สมาชิกทีมคนหนึ่งเคยอ่านมาว่าห้ามใช้ผลลัพธ์เติมตัวทำนายที่หายไป เพราะจะเป็นการรั่วไหลของข้อมูล (data leakage คือการใช้ข้อมูลที่แบบจำลองจะไม่มีตอนถูกนำไปใช้) คำแนะนำนี้รวมสองช่วงเวลาที่ต่างกันเข้าด้วยกัน ขณะสร้างแบบจำลอง ผู้ป่วยทุกรายในทะเบียนมีการบันทึก delirium แล้ว แต่เมื่อใช้คะแนนกับผู้ป่วยรายใหม่ข้างเตียง delirium ยังไม่เกิดขึ้น

บทความนี้ตามทีมผ่านทั้งสองช่วงเวลา แสดงว่าแบบจำลองเป็นอย่างไรเมื่อไม่ใส่ delirium ในขั้นตอนที่เติมค่าที่หายไป (imputation) จากนั้นแสดงว่าผลลัพธ์ทำอะไรได้ในแต่ละขั้นของการตรวจสอบความถูกต้อง (validation คือการตรวจว่าค่าทำนายของคะแนนแม่นยำเพียงใด) และแผนจัดการ albumin ที่หายไปข้างเตียงหน้าตาเป็นอย่างไร

สองแบบจำลองที่มีหน้าที่ต่างกัน

การวิเคราะห์ที่มีค่าเติมเกี่ยวข้องกับสองแบบจำลอง แบบจำลองสำหรับวิเคราะห์ (analysis model) ตอบคำถามทางคลินิก ในที่นี้คือการถดถอยโลจิสติกของ delirium บน albumin และตัวทำนายตอนรับเข้าอีกหกตัว ได้แก่ อายุ เพศ ภาวะเปราะบาง (frailty) ภาวะสมองเสื่อม การใช้ยาต้านการแข็งตัวของเลือด และระดับ ASA 3 ขึ้นไปตามมาตราสถานะร่างกายของสมาคมวิสัญญีแพทย์อเมริกัน (American Society of Anesthesiologists) ส่วน แบบจำลองสำหรับเติมค่า (imputation model) เป็นแบบจำลองช่วยงานที่มีหน้าที่เดียวคือสร้างค่าที่สมเหตุสมผลในที่ที่ albumin หายไป

ให้ $Y$ แทน delirium (1 ถ้าเกิดขึ้น 0 ถ้าไม่เกิด) และ $X$ แทนตัวทำนายตอนรับเข้าทั้งเจ็ดตัว มีเพียง albumin ที่มีช่องว่าง จึงให้ $X_{-\text{alb}}$ แทนตัวทำนายอีกหกตัวที่บันทึกครบ และให้ $R_X$ เป็นตัวบ่งชี้ว่า albumin ซึ่งเป็นตัวทำนายที่หายไปบางส่วนถูกบันทึกไว้ ($R_X = 1$) หรือหายไป ($R_X = 0$) แบบจำลองสำหรับวิเคราะห์คือ

$$\operatorname{logit} P(Y = 1 \mid X) = \beta_0 + \beta_{\text{alb}}\,\text{albumin} + \beta_1\,\text{age} + \dots + \beta_6\,\text{anticoag}$$

กล่าวเป็นคำพูดคือ $\beta_{\text{alb}}$ เป็นการเปลี่ยนแปลงของ log odds ของ delirium ต่อ albumin ที่สูงขึ้น 1 g/L เมื่อกำหนดตัวทำนายอีกหกตัวไว้คงที่ ในทะเบียนจำลอง albumin ที่สูงกว่าสัมพันธ์กับความเสี่ยงที่ต่ำกว่า $\beta_{\text{alb}}$ จึงเป็นค่าลบ แบบจำลองสำหรับเติมค่าทำงานในทิศตรงข้าม คือทำนาย albumin จากตัวแปรที่เหลือ

การเติมค่าที่หายไปหลายครั้ง (multiple imputation, MI) เติมค่าที่หายไปแต่ละค่าหลายครั้ง โดยแต่ละครั้งสุ่มจากแบบจำลองสำหรับเติมค่า แล้วปรับแบบจำลองสำหรับวิเคราะห์เข้ากับข้อมูล (fit) ในทุกชุดข้อมูลที่เติมครบ และรวมผลด้วย กฎของ Rubin (Rubin's rules) ซึ่งบวกความแปรปรวนระหว่างชุดข้อมูลเข้ากับความแปรปรวนภายในชุดข้อมูลตามปกติ ความแปรปรวนส่วนเพิ่มนี้นำความไม่แน่นอนเกี่ยวกับค่าที่หายไปเข้าสู่ช่วงเชื่อมั่น

วิธีที่ใช้ในที่นี้คือ predictive mean matching (PMM) สำหรับผู้ป่วยแต่ละรายที่ไม่มี albumin แบบจำลองสำหรับเติมค่าจะทำนายค่าเฉลี่ย หาผู้ป่วยที่มีบันทึกไว้ซึ่งค่าเฉลี่ยทำนายใกล้เคียงที่สุด (ผู้ให้ค่า หรือ donor) แล้วคัดลอก albumin ของหนึ่งในผู้ให้ค่าเหล่านั้นแบบสุ่ม [1] การรันในบทความนี้ใช้ผู้ให้ค่าสิบราย กำหนดด้วย knn(10) ใน Stata และ donors = 10 ใน R ค่าที่เติมทุกค่าจึงเป็นค่าที่เคยพบจริงในทะเบียน

ทำไมผลลัพธ์จึงสำคัญเมื่อประมาณสัมประสิทธิ์

แบบจำลองสำหรับเติมค่าถ่ายทอดได้เฉพาะความสัมพันธ์ที่ได้รับ ถ้าไม่ใส่ delirium ค่า albumin ที่เติมแต่ละค่าจะสะท้อนอายุ ภาวะเปราะบาง และตัวทำนายอื่นของผู้ป่วยรายนั้น แต่ไม่สะท้อนว่าผู้ป่วยรายนั้นเกิด delirium หรือไม่ ในกลุ่มผู้ป่วยที่คล้ายกัน ค่าที่เติมจึงไม่มีความเชื่อมโยงกับ delirium

การผสมค่าเหล่านั้นกับค่าที่บันทึกไว้จริงทำให้ความสัมพันธ์ที่แบบจำลองสำหรับวิเคราะห์พยายามประมาณจางลง และ $\beta_{\text{alb}}$ ถูกดึงเข้าหาศูนย์ [2, 3] การใส่ $Y$ ไม่ได้ทำให้แบบจำลองแอบดูสิ่งที่ภายหลังจะไม่มี เพราะระหว่างการพัฒนา สัมประสิทธิ์ถูกประมาณจาก $Y$ อยู่แล้ว การใส่ $Y$ เพียงทำให้แบบจำลองสำหรับเติมค่าเห็นพ้องกับแบบจำลองสำหรับวิเคราะห์ว่า albumin กับ delirium สัมพันธ์กัน

แนวทางทั่วไปจึงเป็นดังนี้ เมื่อกำลังประมาณสัมประสิทธิ์ ไม่ว่าในการพัฒนาแบบจำลองหรือในการวิเคราะห์เชิงเหตุผล มักใส่ทุกตัวแปรในแบบจำลองสำหรับวิเคราะห์ รวมทั้งผลลัพธ์ ไว้ในแบบจำลองสำหรับเติมค่า พร้อมปฏิกิริยาสัมพันธ์หรือพจน์ไม่เชิงเส้นที่แบบจำลองสำหรับวิเคราะห์ใช้ [1, 3]

อาจเพิ่มตัวแปรเสริม (auxiliary variable) ซึ่งทำนายค่าที่หายไปหรือโอกาสที่ค่าจะหายไปโดยไม่ได้อยู่ในแบบจำลองสำหรับวิเคราะห์ด้วย ส่วนหลัง ๆ จะแสดงว่าบทบาทของผลลัพธ์เปลี่ยนไปในขั้นตรวจสอบความถูกต้องและที่ข้างเตียง

สิ่งที่ทะเบียนจำลองสมมติเกี่ยวกับการขาดหายของข้อมูล

ก่อนอ่านตัวเลข ให้ดูก่อนว่า albumin หายไปอย่างไรในทะเบียนนี้ albumin ขาดหายแบบสุ่ม (missing at random, MAR) คือโอกาสที่จะมีช่องว่างขึ้นกับข้อมูลที่บันทึกไว้เท่านั้น ผู้ป่วยสูงอายุ ผู้ป่วยเปราะบาง ผู้ป่วยสมองเสื่อม และผู้ป่วยที่มีระดับ ASA สูงไม่มี albumin บ่อยกว่า แต่การวัด albumin หรือไม่ไม่ขึ้นกับค่า albumin เองหรือ delirium

ภายใต้กลไกนี้ การวิเคราะห์เฉพาะรายที่ข้อมูลครบ (complete-case analysis) ซึ่งตัดผู้ป่วย 6,066 รายที่ไม่มี albumin ออกเฉย ๆ ประมาณเป้าหมายเดียวกับที่ข้อมูลครบทั้งชุดจะประมาณได้ เพราะเมื่อรู้ตัวทำนายอื่นแล้ว การที่ albumin หายไปไม่ขึ้นกับ delirium

แบบจำลองสำหรับวิเคราะห์ (analysis model) ในที่นี้เป็นแบบจำลองช่วยงาน (working model) ที่ละพจน์สองตัวที่ใช้สร้างข้อมูลจำลองไว้ คือปฏิกิริยาสัมพันธ์ระหว่างภาวะเปราะบางกับภาวะสมองเสื่อม และช่วงเวลาของการผ่าตัด เป้าหมายของมันจึงเป็น ค่าอ้างอิง (reference value) คือสัมประสิทธิ์ของ albumin ที่แบบจำลองเดียวกันนี้ให้เมื่อปรับกับประชากรจำลองอีกชุดหนึ่งที่มีขนาดใหญ่มาก ซึ่งสร้างด้วยกฎเดียวกันและบันทึก albumin ครบทุกค่า

ในที่นี้ค่าอ้างอิงคือ -0.0962 และช่วงเชื่อมั่นของการวิเคราะห์เฉพาะรายที่ข้อมูลครบครอบคลุมค่านั้น การวิเคราะห์เฉพาะรายที่ข้อมูลครบ (complete case) เสียจำนวนแถวแต่ไม่เสียความตรง ความต่างที่เป็นหัวใจของข้อโต้แย้งคือการเติมค่าโดยไม่ใส่ $Y$ เทียบกับอีกสองวิธี

ทะเบียนเดียว สามวิธีจัดการ albumin ที่หายไป

การตั้งค่า: ชุดพัฒนามีผู้ป่วยจำลอง 20,000 ราย บันทึก albumin ไว้ 13,934 ราย และหายไป 6,066 ราย แต่ละวิธีปรับการถดถอยโลจิสติกข้างต้น และวิธีที่ใช้การเติมค่าจะเติมช่องว่าง 40 ครั้ง ค่าประมาณมาจากการรัน Stata และเพิ่มผลจากการรัน R ในที่ที่สองโปรแกรมต่างกัน เพราะสองโปรแกรมสุ่มเลือกคู่จากผู้ป่วยสิบรายที่ใกล้เคียงที่สุด (ผู้ให้ค่า หรือ donor) ต่างกัน

ไฟล์ทะเบียนจำลองไม่ได้เผยแพร่ไว้ในหน้านี้ จึงสร้างค่าประมาณทั้งสามซ้ำจากหน้านี้ไม่ได้ บันทึกการรันเหล่านี้แสดงสิ่งที่สคริปต์ให้ผลออกมาตรงตามจริง

  1. การวิเคราะห์เฉพาะรายที่ข้อมูลครบ (complete-case analysis)

    ตัดผู้ป่วย 6,066 รายที่ไม่มี albumin ออก แล้วปรับแบบจำลองกับ 13,934 รายที่เหลือ สัมประสิทธิ์ของ albumin คือ -0.1014 (ช่วงเชื่อมั่น 95% -0.1138 ถึง -0.0889) ในทั้งสองโปรแกรม

  2. การเติมค่าที่หายไปหลายครั้ง (multiple imputation) โดยไม่ใส่ผลลัพธ์

    เติม albumin 40 ครั้งจากตัวทำนายอีกหกตัวเท่านั้น ปรับแบบจำลองในแต่ละชุดข้อมูลที่เติมครบ และรวมผลด้วยกฎของ Rubin สัมประสิทธิ์คือ -0.0670 (ช่วงเชื่อมั่น 95% -0.0786 ถึง -0.0555) ส่วน R ได้ -0.0670 (-0.0786 ถึง -0.0554)

  3. การเติมค่าที่หายไปหลายครั้ง (multiple imputation) โดยใส่ผลลัพธ์

    ทำซ้ำขั้นก่อนหน้าโดยเพิ่ม delirium ในแบบจำลองสำหรับเติมค่า (imputation model) สัมประสิทธิ์คือ -0.0991 (ช่วงเชื่อมั่น 95% -0.1114 ถึง -0.0867) ส่วน R ได้ -0.0990 (-0.1125 ถึง -0.0856)

  4. เปรียบเทียบ

    การวิเคราะห์เฉพาะรายที่ข้อมูลครบกับการเติมค่าที่ใส่ delirium ให้ผลใกล้เคียงกันมาก การเติมค่าโดยไม่ใส่ delirium อยู่ใกล้ศูนย์กว่าประมาณหนึ่งในสาม และช่วงเชื่อมั่นของมันไม่ซ้อนทับกับอีกสองวิธีเลย

ผลลัพธ์: การไม่ใส่ delirium ในแบบจำลองสำหรับเติมค่าทำให้ความสัมพันธ์ของ albumin อ่อนลง การใส่กลับเข้าไปทำให้ได้ค่าใกล้เคียงกับการวิเคราะห์เฉพาะรายที่ข้อมูลครบอีกครั้ง ช่วงเชื่อมั่นของการวิเคราะห์เฉพาะรายที่ข้อมูลครบ (complete case) และของการเติมค่าที่ใส่ delirium ทั้งสองครอบคลุม -0.0962 ซึ่งคือค่าอ้างอิงที่นิยามไว้ข้างต้น (สัมประสิทธิ์ของ albumin ที่แบบจำลองช่วยงานเดียวกันนี้ให้เมื่อปรับกับประชากรจำลองอีกชุดหนึ่งที่มีขนาดใหญ่มากและบันทึก albumin ครบทุกค่า) ส่วนช่วงเชื่อมั่นของการเติมค่าโดยไม่ใส่ delirium ไม่ครอบคลุม

ข้อมูลจำลอง ค่าประมาณสัมประสิทธิ์ทั้งสามมาจากการรันสคริปต์ Stata และ R ที่แสดงข้างล่าง ช่องผลลัพธ์ข้างล่างพิมพ์เฉพาะสองตารางสุดท้ายของแต่ละการรัน (ตาราง AUROC และตาราง calibration โดย AUROC คือพื้นที่ใต้กราฟ ROC ซึ่งเป็นคะแนนการจัดอันดับที่นิยามไว้ในส่วนการตรวจสอบความถูกต้องข้างล่าง) และไฟล์ทะเบียนจำลองไม่ได้เผยแพร่ จึงสร้างสัมประสิทธิ์ซ้ำจากหน้านี้ไม่ได้

ทำไมการเติมค่าจึงไม่เพิ่มความแม่นยำในที่นี้

การเติมค่าที่ใส่ delirium ไม่ได้เพิ่มความแม่นยำให้สัมประสิทธิ์ของ albumin เพราะแบบจำลองสำหรับเติมค่า (imputation model) ในที่นี้มีเพียงตัวแปรของแบบจำลองสำหรับวิเคราะห์ (analysis model) ผู้ป่วยที่ไม่มี albumin จึงให้ข้อมูลเกี่ยวกับสัมประสิทธิ์ของ albumin เองน้อย เรื่องนี้พบได้บ่อยเมื่อไม่มีตัวแปรเสริมที่ทำนาย albumin ได้ดี ประโยชน์จากการเติมค่าที่หายไปหลายครั้งจะเห็นเป็นหลักในสัมประสิทธิ์ของตัวทำนายที่บันทึกครบ หรือเมื่อมีตัวแปรเสริมที่ทำนาย albumin

ข้อมูลจำลอง เลื่อนสัดส่วนของ albumin ที่หายไป และสลับว่าจะใส่ผลลัพธ์ในแบบจำลองสำหรับเติมค่าหรือไม่ จุดเดี่ยวคือค่าประมาณที่รวมผลแล้วจากทะเบียนจำลองซึ่ง albumin หายไป 6,066 จาก 20,000 ค่า ส่วนเส้นเป็นค่าประมาณคร่าว ๆ ว่าสัมประสิทธิ์เปลี่ยนไปอย่างไรตามสัดส่วนที่หายไป ไม่ใช่การรันเพิ่ม เส้นประคือเส้นอ้างอิง และเส้นอ้างอิงคือค่าอ้างอิง -0.0962 ซึ่งคือแบบจำลองเดียวกันนี้ที่ปรับกับประชากรจำลองอีกชุดหนึ่งที่มีขนาดใหญ่มากและบันทึก albumin ครบทุกค่า ไม่แสดงความสามารถในการจำแนก เพราะแทบไม่เปลี่ยน

โค้ดเบื้องหลังค่าประมาณทั้งสาม

ช่องซอร์สโค้ดของรูปโค้ดแต่ละรูปแสดงสคริปต์ทั้งไฟล์ ซึ่งรวมส่วนที่บทความนี้ไม่ได้ใช้ ส่วนที่ใช้ในบทความนี้เริ่มที่คอมเมนต์ prediction model for delirium with albumin missing และจบที่สองบรรทัดที่พิมพ์ค่าอ้างอิง โค้ดก่อนและหลังช่วงนั้นข้ามไปได้ ช่องผลลัพธ์แสดงเฉพาะสองตารางสุดท้ายที่ส่วนนี้พิมพ์ คือ AUROC หลังเติมค่า albumin ของชุดตรวจสอบที่ถูกปิดค่า (masked คือถูกลบออกโดยตั้งใจ ตามที่อธิบายข้างล่าง) ด้วยสี่วิธี และตาราง calibration

ในซอร์สโค้ด คำสั่งอย่าง canon canonn และ calprint เขียนตัวเลขทีละบรรทัดลงในบันทึกการรันฉบับเต็ม เพื่อให้ตรวจตัวเลขแต่ละตัวในบทความกับบันทึกได้ โดยไม่เปลี่ยนค่าประมาณใดเลย

ภายในช่วงนั้น ทั้งสองสคริปต์ปรับแบบจำลองเข้ากับข้อมูล (fit) ทั้งสามวิธีกับทะเบียนจำลองเดียวกัน ใน Stata แบบจำลองสำหรับเติมค่าคือบรรทัดที่ขึ้นต้นด้วย mi impute chained ส่วนฉบับที่ไม่ใส่ผลลัพธ์ตัด delirium ออกจากด้านขวาของเครื่องหมายเท่ากับ ใน R การเลือกแบบเดียวกันทำผ่านเมทริกซ์ตัวทำนาย (predictor matrix) ซึ่งบอก mice แพ็กเกจ R สำหรับการเติมค่าที่หายไปหลายครั้ง ว่าตัวแปรใดทำนายตัวแปรใดได้

ในทั้งสองสคริปต์ แบบจำลองของ albumin เห็นเพียงตัวทำนายตอนรับเข้าหกตัว บวก delirium ในฉบับที่สอง รหัสผู้ป่วย การเสียชีวิต ช่วงเวลาของการผ่าตัด และเวลาติดตามผลถูกกันออก แบบจำลองสำหรับเติมค่าจึงมีตัวแปรเท่ากับแบบจำลองสำหรับวิเคราะห์พอดี

Stata: สามวิธี พร้อมตาราง AUROC และตาราง calibration

โค้ด Stata w1_sim.do
* 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 (ข้อมูลจำลอง)."
ผลลัพธ์จากการรัน w1_sim.log
. * 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)

AUCM[4,1]:  AUROC after filling masked validation albumin, simulated data
         auroc
  full  0.8777
regimp  0.8776
  mi_y  0.8796
mi_noy  0.8753

. * 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)

CAL[7,6]:  Calibration on the validation set, simulated data
               slope  slope_lo  slope_hi      citl   citl_lo   citl_hi
        cc    0.9970    0.9416    1.0524    0.0399   -0.0392    0.1191
    mi_noy    0.9876    0.9328    1.0424    0.0249   -0.0545    0.1043
      mi_y    0.9718    0.9179    1.0257    0.0264   -0.0535    0.1063
  val_full    0.9970    0.9416    1.0524    0.0399   -0.0392    0.1191
val_regimp    0.9928    0.9377    1.0478    0.0303   -0.0489    0.1095
  val_mi_y    0.9992    0.9427    1.0556    0.0260   -0.0539    0.1059
val_mi_noy    0.9745    0.9195    1.0296    0.0259   -0.0541    0.1059
ข้อมูลจำลอง ซอร์สโค้ดและผลลัพธ์จากการรัน Stata จริง ช่องซอร์สโค้ดแสดงทั้งไฟล์ ส่วนที่เกี่ยวกับบทความนี้เริ่มที่คอมเมนต์ 'prediction model for delirium with albumin missing' ช่องผลลัพธ์แสดงสองตารางสุดท้ายของส่วนนั้น ในตาราง AUROC แถว full คือ albumin บันทึกครบ regimp คือการเติมค่าด้วยการถดถอย ส่วน mi_y และ mi_noy คือการเติมค่าที่หายไปหลายครั้งโดยใช้และไม่ใช้ผลลัพธ์ของชุดตรวจสอบ ในตาราง calibration แถว cc mi_noy และ mi_y คือแบบจำลองที่ปรับแล้วทั้งสามเมื่อ albumin ของชุดตรวจสอบบันทึกครบ และแถวที่ขึ้นต้นด้วย val_ คือแบบจำลองเฉพาะรายที่ข้อมูลครบหลังเติม albumin ที่ถูกปิดค่าด้วยแต่ละวิธี

R: สามวิธีเดียวกันด้วย mice และสองตารางเดียวกัน

โค้ด R w1_sim_r.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")
ผลลัพธ์จากการรัน w1_sim_r.log
> cat("AUROC of the complete-case model after the masked validation albumin was filled four ways, simulated data\n")
AUROC of the complete-case model after the masked validation albumin was filled four ways, simulated data

> print(round(auc_mask, 4))
  full regimp   mi_y mi_noy
0.8777 0.8776 0.8797 0.8752

> cat("Calibration on the validation set, simulated data\n")
Calibration on the validation set, simulated data

> print(round(cal_tab, 4))
            slope slope_lo slope_hi   citl citl_lo citl_hi
cc         0.9970   0.9416   1.0524 0.0399 -0.0392  0.1191
mi_noy     0.9873   0.9325   1.0420 0.0253 -0.0541  0.1047
mi_y       0.9720   0.9181   1.0260 0.0269 -0.0530  0.1068
val_full   0.9970   0.9416   1.0524 0.0399 -0.0392  0.1191
val_regimp 0.9928   0.9377   1.0478 0.0303 -0.0489  0.1095
val_mi_y   0.9979   0.9408   1.0550 0.0259 -0.0542  0.1060
val_mi_noy 0.9735   0.9184   1.0285 0.0246 -0.0555  0.1047
ข้อมูลจำลอง ซอร์สโค้ดและผลลัพธ์จากการรัน R จริง ช่องซอร์สโค้ดแสดงทั้งไฟล์ ส่วนที่เกี่ยวกับบทความนี้เริ่มที่คอมเมนต์ 'prediction model for delirium with albumin missing' ช่องผลลัพธ์แสดงสองตารางเดียวกับช่อง Stata โดยใช้ชื่อแถวเดียวกัน R สุ่มผู้ให้ค่า (donor) ของตัวเอง แถวที่ใช้การเติมค่าที่หายไปหลายครั้งจึงต่างจากการรัน Stata เล็กน้อย

การรั่วไหลขึ้นกับว่ารู้อะไร และรู้เมื่อใด

การรั่วไหลของข้อมูล (data leakage) คือการใช้ข้อมูลที่แบบจำลองจะไม่มีตอนถูกนำไปใช้ ในขณะสร้างหรือประเมินแบบจำลอง ประเด็นอยู่ที่ว่ามีข้อมูลนั้นหรือไม่ ไม่ใช่ว่าผลลัพธ์ปรากฏในแบบจำลองสำหรับเติมค่าหรือเปล่า ในทุกขั้นคำถามเดียวกันคือ ที่ขั้นนี้รู้ $Y$ หรือยัง และจะรู้ตอนใช้คะแนนหรือไม่

ระหว่างการพัฒนา ผู้ป่วยทุกรายในทะเบียนมีข้อมูล delirium และสัมประสิทธิ์ถูกประมาณจากข้อมูลนั้นอยู่แล้ว การใช้มันเติม albumin จึงไม่ได้เพิ่มสิ่งที่แบบจำลองที่ปรับเข้ากับข้อมูล (fitted) ไม่ได้ใช้อยู่ มันเพียงรักษาความสัมพันธ์ของ albumin ไว้

การตรวจสอบความถูกต้องภายใน (internal validation) ประเมินประสิทธิภาพในผู้ป่วยรายใหม่จากแหล่งเดียวกัน ทางหนึ่งใช้ ตัวอย่าง bootstrap (bootstrap sample) คือชุดข้อมูลใหม่ขนาดเท่าเดิมที่ได้จากการสุ่มผู้ป่วยซ้ำแบบใส่คืน (re-sampling with replacement) ผู้ป่วยบางรายจึงถูกเลือกมากกว่าหนึ่งครั้งและบางรายไม่ถูกเลือกเลย

กระบวนการพัฒนาทั้งหมดถูกทำซ้ำในตัวอย่าง bootstrap แต่ละชุด แบบจำลองที่ปรับใหม่แต่ละตัวถูกให้คะแนนในตัวอย่างของมันเองและในข้อมูลเดิม ส่วนต่างเฉลี่ยระหว่างคะแนนทั้งสองคือ optimism (ความมองโลกในแง่ดีเกินจริงของคะแนนบนข้อมูลพัฒนา) และการหักค่านี้ออกจากประสิทธิภาพบนข้อมูลพัฒนาคือ การแก้ค่า optimism (optimism correction)

อีกทางหนึ่งคือ การตรวจสอบไขว้ (cross-validation) ซึ่งแบ่งข้อมูลออกเป็นหลายส่วนที่เรียกว่ากลุ่มย่อย (fold) ปรับแบบจำลองโดยกันไว้หนึ่ง fold ให้คะแนน fold ที่กันไว้ แล้วทำซ้ำจนทุก fold ถูกกันไว้ครบคนละหนึ่งครั้ง

การปรับใหม่ที่ข้ามขั้นเติมค่าไม่ได้เลียนแบบการพัฒนา การเติมค่าครั้งเดียวกับข้อมูลทั้งชุดก่อนสุ่มซ้ำทำให้ผลลัพธ์ของข้อมูลทดสอบมีอิทธิพลต่อค่า albumin ที่ใช้ให้คะแนนผู้ป่วยกลุ่มเดียวกันนั้น และประสิทธิภาพจะดูดีกว่าความเป็นจริง ภายในแต่ละรอบ ให้เติมค่าข้อมูลฝึกด้วยผลลัพธ์ และเติม albumin ที่หายไปในข้อมูลทดสอบแบบเดียวกับที่แผนข้างเตียงจะใช้ โดยไม่ใช้ผลลัพธ์ของข้อมูลทดสอบ เมื่อเป้าหมายคือประสิทธิภาพในการใช้งานจริง

การตรวจสอบความถูกต้องภายนอก (external validation) ทดสอบแบบจำลองที่สร้างเสร็จแล้วในตัวอย่างแยกต่างหาก เช่น โรงพยาบาลอื่นหรือช่วงเวลาหลังจากนั้น ตัวทำนายในชุดตรวจสอบที่เติมโดยอาศัยผลลัพธ์ของชุดตรวจสอบเองจะใช้ข้อมูลที่เครื่องมือข้างเตียงจะไม่มี

การนำไปใช้ (deployment) คือการใช้คะแนนกับผู้ป่วยจริง ซึ่งยังไม่ทราบว่าเกิด delirium หรือไม่ แนวทางสำหรับ albumin ที่หายไปในขั้นนั้นได้แก่ การเติมค่าด้วยการถดถอย (regression imputation) (การเติมช่องว่างแต่ละช่องครั้งเดียวด้วยค่าที่ทำนายจากตัวทำนายอื่น โดยใช้แบบจำลองที่ปรับจากข้อมูลพัฒนาโดยไม่ใช้ผลลัพธ์) แบบจำลองย่อยแยกสำหรับผู้ป่วยที่ไม่มี albumin หรือการวัด albumin ก่อนคำนวณคะแนน [4]

รู้ delirium แล้วหรือยัง ไล่ตามขั้น

คำถามเดียวกันที่ถามในแต่ละขั้นของวงจรชีวิตแบบจำลองทำนาย
ขั้นรู้ delirium แล้วหรือไม่การใช้ delirium ในการเติมค่าจะหมายความว่าอย่างไร
การพัฒนา (development)รู้ ในผู้ป่วยทุกรายใช้ข้อมูลที่แบบจำลองที่ปรับเข้ากับข้อมูล (fitted) ใช้อยู่แล้ว
การตรวจสอบความถูกต้องภายใน (internal validation: bootstrap หรือ cross-validation)รู้ใช้ได้กับข้อมูลฝึกเมื่อทำซ้ำภายในทุกรอบของการสุ่มซ้ำ โดยเติม albumin ที่หายไปในข้อมูลทดสอบแบบเดียวกับที่แผนข้างเตียงจะใช้ คือไม่ใช้ผลลัพธ์ของข้อมูลทดสอบ เมื่อเป้าหมายคือประสิทธิภาพในการใช้งานจริง แต่เป็นการรั่วไหลเมื่อทำครั้งเดียวก่อนสุ่มซ้ำ
การตรวจสอบความถูกต้องภายนอก (external validation)รู้ หลังติดตามผลใช้ข้อมูลที่เครื่องมือข้างเตียงจะไม่มี เมื่อเป้าหมายคือประสิทธิภาพในการใช้งานจริง
การนำไปใช้ (deployment, ผู้ป่วยรายใหม่)ไม่รู้เป็นไปไม่ได้: delirium ยังไม่เกิดขึ้น

AUROC แสดงอะไรและไม่แสดงอะไร

ความสามารถในการจำแนก (discrimination) คือความสามารถของคะแนนในการจัดให้ผู้ป่วยที่เกิดผลลัพธ์มีความเสี่ยงทำนายสูงกว่าผู้ป่วยที่ไม่เกิด มักสรุปด้วย AUROC คือพื้นที่ใต้กราฟ receiver operating characteristic ซึ่งเป็นความน่าจะเป็นที่ผู้ป่วยซึ่งเกิด delirium ที่สุ่มมาหนึ่งรายจะได้ความเสี่ยงทำนายสูงกว่าผู้ป่วยที่ไม่เกิดที่สุ่มมาหนึ่งราย แบบจำลองที่ปรับเข้ากับข้อมูล (fitted) แต่ละตัวถูกให้คะแนนในชุดตรวจสอบความถูกต้อง (validation set) จำลองแยกต่างหากที่มีผู้ป่วย 5,000 ราย ซึ่งบันทึก albumin ครบทุกราย

สามแบบจำลองแยกกันไม่ออกที่นั่น: 0.8777 สำหรับการวิเคราะห์เฉพาะรายที่ข้อมูลครบ (complete case) 0.8776 สำหรับการเติมค่าโดยไม่ใส่ delirium และ 0.8777 สำหรับการเติมค่าที่ใส่ delirium สัมประสิทธิ์ที่ถูกดึงเข้าหาศูนย์แทบไม่เปลี่ยนการจัดอันดับผู้ป่วย เหตุผลหนึ่งที่เป็นไปได้คือตัวทำนายอื่น เช่น ภาวะเปราะบางและภาวะสมองเสื่อม อาจเป็นตัวกำหนดการจัดอันดับส่วนใหญ่ แต่การรันเหล่านี้ไม่ได้ทดสอบเรื่องนี้

AUROC ที่คงที่จึงไม่ได้แสดงว่าสัมประสิทธิ์ไม่ลำเอียง: สัมประสิทธิ์ของ albumin ขยับจาก -0.1014 ไปเป็น -0.0670 ขณะที่การจัดอันดับผู้ป่วยแทบไม่เปลี่ยน

AUROC ในชุดตรวจสอบความถูกต้อง (validation set) แทบเท่ากันทั้งสามกลยุทธ์

ตารางนี้แสดงค่าระหว่างการพัฒนาด้วย ค่าเหล่านี้เป็นค่าที่ปรากฏ (apparent) คือให้คะแนนบนแถวที่ใช้ปรับแบบจำลอง และไม่นำมาเปรียบเทียบข้ามคอลัมน์ ค่าของการวิเคราะห์เฉพาะรายที่ข้อมูลครบคำนวณจาก 13,934 แถว ส่วนค่าของการเติมค่าคำนวณจากทั้ง 20,000 แถวที่เติมครบ ค่าของการเติมค่าที่ใส่ delirium ยังถูกให้คะแนนบนค่า albumin ที่สุ่มโดยใช้ผลลัพธ์ของผู้ป่วยแต่ละรายเอง จึงดีเกินจริงโดยโครงสร้าง ซึ่งคือการรั่วไหลแบบเดียวกับที่อธิบายไว้สำหรับการเติมค่าก่อนสุ่มซ้ำ

ข้อมูลจำลอง AUROC จากการรัน Stata ค่าระหว่างการพัฒนาเป็นค่าที่ปรากฏ (apparent คือให้คะแนนบนแถวที่ใช้ปรับแบบจำลอง) และไม่นำมาเปรียบเทียบข้ามคอลัมน์ ค่าของการวิเคราะห์เฉพาะรายที่ข้อมูลครบคำนวณจาก 13,934 แถว ส่วนค่าของการเติมค่าคำนวณจากทั้ง 20,000 แถวที่เติมครบ และค่าของการเติมค่าที่ใส่ delirium ยังถูกให้คะแนนบนค่า albumin ที่สุ่มโดยใช้ผลลัพธ์ของผู้ป่วยแต่ละรายเอง จึงดีเกินจริงโดยโครงสร้าง
ที่ที่แบบจำลองถูกให้คะแนนcomplete case (วิเคราะห์เฉพาะรายที่ข้อมูลครบ)MI (multiple imputation) โดยไม่ใส่ deliriumMI (multiple imputation) โดยใส่ delirium
แถวระหว่างการพัฒนา (development, apparent)0.86680.87700.8805
validation set (ชุดตรวจสอบความถูกต้อง) ผู้ป่วย 5,000 ราย บันทึก albumin ครบ0.87770.87760.8777

แบบจำลองคงที่หนึ่งตัว albumin ของชุดตรวจสอบที่หายไปถูกเติมสี่วิธี

ตารางข้างล่างถามคำถามระดับการตรวจสอบความถูกต้อง ในชุดตรวจสอบ (validation set) albumin 1,499 จาก 5,000 ค่าถูกปิดค่า (masked) คือถูกลบออกโดยตั้งใจด้วยกฎตายตัวที่ขึ้นกับตัวทำนายอื่นเท่านั้น (ค่าเหล่านี้จึงขาดหายแบบสุ่ม หรือ missing at random)

แบบจำลองคงที่หนึ่งตัว คือแบบจำลองเฉพาะรายที่ข้อมูลครบ (complete-case fit) ถูกให้คะแนนหลังเติมค่าด้วยแต่ละวิธี เฉพาะการรันที่ใช้ผลลัพธ์ของชุดตรวจสอบเท่านั้นที่เกี่ยวข้องกับการรั่วไหล และค่าของมันสูงกว่าค่าที่บันทึกครบ ซึ่งเป็นทิศที่การรั่วไหลจะดันไป AUROC ทั้งสี่ค่าต่างกันเพียงทศนิยมตำแหน่งที่สาม และไม่มีช่วงเชื่อมั่น จึงไม่มีค่าใดเป็นข้อค้นพบที่เข้าข้างหรือค้านกลยุทธ์ใด

ข้อมูลจำลอง แบบจำลองเฉพาะรายที่ข้อมูลครบ (complete-case model) ถูกให้คะแนนในชุดตรวจสอบ (validation set) หลังปิดค่า (ลบออกโดยตั้งใจ) albumin 1,499 จาก 5,000 ค่าด้วยกฎตายตัวที่ขึ้นกับตัวทำนายอื่นเท่านั้น ในวิธีที่ใช้ MI ค่า AUROC เป็นค่าเฉลี่ยจากการเติมค่า 40 ครั้ง ความต่างไม่มีช่วงเชื่อมั่นและไม่ใช่ข้อค้นพบ
วิธีเติม albumin ของชุดตรวจสอบ (validation set) ที่หายไปAUROC, StataAUROC, R
ไม่ปิดค่า (albumin บันทึกครบ)0.87770.8777
การเติมค่าด้วยการถดถอย (regression imputation) ที่ปรับจากข้อมูลพัฒนา ไม่ใช้ผลลัพธ์0.87760.8776
MI โดยใช้ผลลัพธ์ของชุดตรวจสอบ0.87960.8797
MI โดยไม่ใช้ผลลัพธ์ของชุดตรวจสอบ0.87530.8752

calibration: ไม่มีกลยุทธ์ใดโดดเด่นในการรันเหล่านี้

สำหรับคะแนนที่จะถูกนำไปใช้ข้างเตียง ความสามารถในการจำแนกยังไม่พอ ควรตรวจ calibration ซึ่งคือความสอดคล้องระหว่างความเสี่ยงที่ทำนายกับความเสี่ยงที่สังเกตได้ด้วย ในที่นี้ใช้ค่าสรุปสองค่า ความชันของ calibration (calibration slope) คือความชันของการถดถอยโลจิสติกของผลลัพธ์ที่สังเกตได้บนตัวทำนายเชิงเส้น (linear predictor คือคะแนนในมาตรา log-odds) ของแบบจำลอง ค่า 1 หมายความว่าค่าทำนายไม่สุดโต่งเกินไปและไม่ค่อนกลางเกินไป

ความสอดคล้องโดยรวมของระดับความเสี่ยง (calibration-in-the-large, CITL) คือจุดตัดแกนของการถดถอยเดียวกันเมื่อกำหนดความชันไว้ที่ 1 ค่า 0 หมายความว่าความเสี่ยงทำนายเฉลี่ยเท่ากับความเสี่ยงที่สังเกตได้ และค่าบวกหมายความว่าความเสี่ยงทำนายต่ำไปเล็กน้อยโดยเฉลี่ย ตารางข้างล่างให้ทั้งสองค่าพร้อมช่วงเชื่อมั่น 95% สำหรับแบบจำลองที่ปรับแล้วทั้งสามในชุดตรวจสอบ และสำหรับแบบจำลองเฉพาะรายที่ข้อมูลครบหลังเติม albumin ของชุดตรวจสอบที่ถูกปิดค่าด้วยแต่ละวิธี

ความชันทุกค่าอยู่ใกล้ 1 และ CITL ทุกค่าอยู่ใกล้ 0 และช่วงเชื่อมั่น 95% ทุกช่วงครอบคลุม 1 สำหรับความชัน และ 0 สำหรับ CITL ผลนี้เป็นจริงแม้กับแบบจำลองที่พัฒนาด้วยการเติมค่าโดยไม่ใส่ delirium: ความชัน 0.9876 (ช่วงเชื่อมั่น 95% 0.9328 ถึง 1.0424) ในการจำลองนี้ ทั้งความสามารถในการจำแนกและ calibration ไม่ได้เผยให้เห็นสัมประสิทธิ์ที่ถูกดึงเข้าหาศูนย์ เหตุผลหนึ่งที่เป็นไปได้คือสัมประสิทธิ์ตัวอื่นขยับตามไปด้วย ซึ่งการรันเหล่านี้ไม่ได้ทดสอบ

ข้อมูลเหล่านี้จึงไม่ได้ชี้ว่ากลยุทธ์การพัฒนาแบบใดดีกว่าสำหรับคะแนนข้างเตียง ความต่างระหว่างกลยุทธ์อยู่ที่สัมประสิทธิ์ของ albumin เอง ซึ่งสำคัญทุกครั้งที่มีการอ่านหรือรายงานสัมประสิทธิ์ ช่วงเชื่อมั่นของการเติมค่าด้วยการถดถอยครั้งเดียวไม่ได้รวมความไม่แน่นอนของค่าที่เติม จึงแคบเกินไป ส่วนแถวที่ใช้การเติมค่าที่หายไปหลายครั้งรวมผลจากการเติมค่า 40 ครั้งด้วยกฎของ Rubin

ข้อมูลจำลอง calibration ในชุดตรวจสอบความถูกต้องที่มีผู้ป่วย 5,000 ราย จากการรัน Stata (ค่าประมาณและช่วงเชื่อมั่น 95%) ความชัน 1 และ calibration-in-the-large 0 หมายถึง calibration สมบูรณ์ สามแถวแรกให้คะแนนแบบจำลองที่ปรับแล้วแต่ละตัวเมื่อ albumin บันทึกครบ สามแถวหลังให้คะแนนแบบจำลองเฉพาะรายที่ข้อมูลครบหลังปิดค่า albumin ของชุดตรวจสอบ 1,499 ค่าแล้วเติมกลับ ตารางจากการรัน R ในช่องผลลัพธ์ของ R ต่างจากค่าเหล่านี้เพียงทศนิยมตำแหน่งที่สามหรือสี่ ในแถวที่ใช้การเติมค่าที่หายไปหลายครั้ง
แบบจำลองที่ให้คะแนน และวิธีจัดการ albumin ของชุดตรวจสอบความชันของ calibration (ช่วงเชื่อมั่น 95%)calibration-in-the-large (ช่วงเชื่อมั่น 95%)
แบบจำลองเฉพาะรายที่ข้อมูลครบ albumin บันทึกครบ0.9970 (0.9416 ถึง 1.0524)0.0399 (-0.0392 ถึง 0.1191)
แบบจำลองจาก MI โดยไม่ใส่ delirium, albumin บันทึกครบ0.9876 (0.9328 ถึง 1.0424)0.0249 (-0.0545 ถึง 0.1043)
แบบจำลองจาก MI โดยใส่ delirium, albumin บันทึกครบ0.9718 (0.9179 ถึง 1.0257)0.0264 (-0.0535 ถึง 0.1063)
แบบจำลองเฉพาะรายที่ข้อมูลครบ เติม albumin ที่ถูกปิดค่าด้วยการถดถอยที่ไม่ใช้ผลลัพธ์0.9928 (0.9377 ถึง 1.0478)0.0303 (-0.0489 ถึง 0.1095)
แบบจำลองเฉพาะรายที่ข้อมูลครบ เติม albumin ที่ถูกปิดค่าด้วย MI โดยใช้ผลลัพธ์ของชุดตรวจสอบ0.9992 (0.9427 ถึง 1.0556)0.0260 (-0.0539 ถึง 0.1059)
แบบจำลองเฉพาะรายที่ข้อมูลครบ เติม albumin ที่ถูกปิดค่าด้วย MI โดยไม่ใช้ผลลัพธ์ของชุดตรวจสอบ0.9745 (0.9195 ถึง 1.0296)0.0259 (-0.0541 ถึง 0.1059)

MCAR, MAR และ MNAR โดยสังเขป

ข้อมูลหายไปด้วยกลไกใดเป็นตัวกำหนดว่าวิธีใดกู้คืนอะไรได้ และกลไกนี้เป็นคุณสมบัติของข้อมูล ไม่ใช่ของแบบจำลองสำหรับเติมค่า [3] ข้อมูล ขาดหายแบบสุ่มสมบูรณ์ (missing completely at random, MCAR) คือโอกาสที่ albumin จะหายไป $P(R_X = 0)$ เท่ากันสำหรับผู้ป่วยทุกราย ไม่ว่าค่าที่บันทึกหรือไม่ได้บันทึกของเขาจะเป็นเท่าใด การวิเคราะห์เฉพาะรายที่ข้อมูลครบจึงไม่ลำเอียงแต่ทิ้งข้อมูลไป

ข้อมูล ขาดหายแบบสุ่ม (MAR) คือโอกาสที่จะมีช่องว่างขึ้นกับข้อมูลที่บันทึกไว้เท่านั้น:

$$P(R_X = 0 \mid \text{albumin}, X_{-\text{alb}}, Y) = P(R_X = 0 \mid X_{-\text{alb}}, Y)$$

สมการนี้บอกว่าเมื่อรู้ตัวทำนายอีกหกตัวและผลลัพธ์แล้ว ค่า albumin ที่ไม่ได้บันทึกไม่ได้บอกอะไรเพิ่มเกี่ยวกับการที่มันหายไป การเติมค่าที่หายไปหลายครั้งแบบมาตรฐานสมมติ MAR และต้องใส่ตัวแปรที่บันทึกไว้ซึ่งทำนายการหายไปไว้ในแบบจำลองสำหรับเติมค่า ในทะเบียนจำลอง การหายไปขึ้นกับตัวทำนายอีกหกตัวเท่านั้น ($X_{-\text{alb}}$) ไม่ขึ้นกับ albumin หรือ delirium ซึ่งเป็นกรณีพิเศษของ MAR

ข้อมูล ขาดหายแบบไม่สุ่ม (missing not at random, MNAR) คือการหายไปยังขึ้นกับค่าที่ไม่ได้บันทึก แม้คำนึงถึงทุกอย่างที่บันทึกไว้แล้ว ตัวอย่างคือ albumin ถูกสั่งตรวจเป็นหลักเมื่อแพทย์สงสัยว่าต่ำ ด้วยเหตุผลที่ไม่เหลือร่องรอยในเวชระเบียน การใส่ $Y$ อาจอธิบายรูปแบบเช่นนั้นได้บางส่วน แต่เปลี่ยน MNAR ให้เป็น MAR ไม่ได้ จึงต้องทำการวิเคราะห์ความไว (sensitivity analysis) บทความเรื่อง กลไกของข้อมูลที่ขาดหาย (missing-data mechanisms, ภาษาอังกฤษ) อธิบายลึกกว่านี้

การเติมค่าหลายครั้งเทียบกับการเติมค่าทำนายครั้งเดียว (single fitted value)

วิธีที่ง่ายกว่าคือเติมช่องว่างแต่ละช่องครั้งเดียวด้วยค่าที่แบบจำลองสำหรับเติมค่าทำนาย สำหรับการประมาณสัมประสิทธิ์ วิธีนี้พลาดสองทาง ทุกค่าที่เติมอยู่บนเส้นถดถอย albumin จึงดูแปรปรวนน้อยกว่าความจริง และค่าที่เดาถูกปฏิบัติเหมือนค่าที่วัดได้ ค่าคลาดเคลื่อนมาตรฐานจึงเล็กเกินไป การเติมค่าที่หายไปหลายครั้งหลีกเลี่ยงทั้งสองอย่างโดยสุ่มค่าพร้อมความไม่แน่นอนของมัน และให้กฎของ Rubin ขยายช่วงเชื่อมั่น

เครื่องมือข้างเตียงมีหน้าที่ต่างออกไป คือหนึ่งการทำนายสำหรับผู้ป่วยหนึ่งราย การเติมค่าด้วยการถดถอยครั้งเดียวที่ปรับจากข้อมูลพัฒนาโดยไม่ใช้ผลลัพธ์เป็นวิธีที่ชอบธรรมในการเติม albumin ที่หายไปตรงนั้น ในการรันที่ albumin ของชุดตรวจสอบ 1,499 จาก 5,000 ค่าถูกปิดค่า วิธีนี้ได้ 0.8776 เทียบกับ 0.8777 เมื่อ albumin บันทึกครบ สำหรับกลไกการตั้งค่าการเติมค่า ดู การเติมค่าที่หายไปหลายครั้งในงานวิจัยทางคลินิก (multiple imputation in clinical research, ภาษาอังกฤษ)

ตรรกะเดียวกันในการวิเคราะห์เชิงเหตุผล

ข้อโต้แย้งนี้ไม่ได้เฉพาะกับการทำนาย เมื่อการศึกษาหนึ่งประมาณผลของการรักษาและตัวกวน (confounder) มีค่าที่หายไป แบบจำลองสำหรับเติมค่าของตัวกวนนั้นควรใส่การรักษาและผลลัพธ์ การไม่ใส่ผลลัพธ์ทำให้ความเชื่อมโยงของตัวกวนกับผลลัพธ์อ่อนลงและอาจลบล้างการปรับไปบางส่วน [1] การศึกษาแบบนี้ไม่มีขั้นข้างเตียง ปัญหาเรื่องการนำไปใช้จึงไม่เกิดขึ้น

เมื่อผลลัพธ์เป็นเวลาจนเกิดเหตุการณ์ (time-to-event) ผลลัพธ์เข้าสู่แบบจำลองสำหรับเติมค่าในรูปตัวบ่งชี้เหตุการณ์ร่วมกับตัวประมาณ Nelson-Aalen ของ hazard สะสม (ผลรวมสะสมของอัตราการเกิดเหตุการณ์ ณ ขณะหนึ่ง ของผู้ป่วยแต่ละรายจนถึงเวลาติดตามผล) ไม่ใช่เวลาติดตามผลดิบ [5] เมื่อแบบจำลองสำหรับเติมค่าละพจน์ที่แบบจำลองสำหรับวิเคราะห์พึ่งพา ทั้งสองแบบจำลองเรียกว่า uncongenial ค่าประมาณที่รวมผลอาจถูกดึงเข้าหาศูนย์ เหมือนตอนที่ไม่ใส่ delirium ในการเติม albumin ข้างต้น และค่าคลาดเคลื่อนมาตรฐานที่รวมผลอาจชวนให้เข้าใจผิด [6]

กฎที่แก้ให้ถูกต้องแล้ว

แต่ละขั้นข้างต้นให้กฎหนึ่งข้อย่อย ข้อย่อยแรกปกป้องสัมประสิทธิ์ เมื่อตัวทำนายจะหายไปด้วยตอนนำไปใช้ อาจเลือกกลยุทธ์การพัฒนาร่วมกับแผนข้างเตียง ตามที่ย่อหน้าหลังกฎอธิบาย เมื่อกล่าวครบถ้วนคือ:

ในขั้นพัฒนาแบบจำลองทำนาย ให้ใส่ผลลัพธ์ไว้ในแบบจำลองที่ใช้เติมค่าตัวทำนายที่หายไป และทำการเติมค่าซ้ำภายในทุกรอบของการสุ่มซ้ำ เมื่อกลุ่มตรวจสอบความถูกต้อง (validation sample) มีค่าตัวทำนายที่หายไป ให้เติมค่าเหล่านั้นโดยไม่ใช้ผลลัพธ์ของกลุ่มนั้น หากเป้าหมายคือการประเมินประสิทธิภาพภายใต้วิธีจัดการข้อมูลที่หายไปแบบเดียวกับที่จะใช้ในการใช้งานจริง ส่วนผลลัพธ์ยังจำเป็นสำหรับการให้คะแนนค่าทำนาย [4] และสำหรับผู้ป่วยรายใหม่ซึ่งยังไม่ทราบผลลัพธ์ ให้วางแผนจัดการค่าตัวทำนายที่หายไปด้วยวิธีที่ไม่ใช้ผลลัพธ์

การศึกษาจำลองหนึ่งรายงานว่าทางเลือกในการพัฒนาอาจขึ้นกับแผนข้างเตียง เมื่อคาดว่าตัวทำนายจะหายไปตอนนำไปใช้และจัดการโดยไม่ใช้ผลลัพธ์ การไม่ใส่ผลลัพธ์ในแบบจำลองสำหรับเติมค่าตอนพัฒนาเป็นทางที่ดีกว่า [7] นั่นคือสถานการณ์ของทีมนี้ถ้า albumin จะหายไปบ่อยข้างเตียงและเติมด้วยการถดถอยที่ไม่ใส่ delirium

ในการรันข้างต้น calibration ในชุดตรวจสอบไม่พบความคลาดเคลื่อนที่ตรวจจับได้ในกลยุทธ์การพัฒนาใดเลย ทั้งเมื่อ albumin บันทึกครบและเมื่อเติมด้วยการถดถอยที่ไม่ใส่ delirium ข้อมูลเหล่านี้จึงตัดสินทางเลือกนี้ไม่ได้ ทีมในตำแหน่งนี้จึงอาจพัฒนาทั้งสองฉบับ แล้วเปรียบเทียบความสามารถในการจำแนกและ calibration ในข้อมูลตรวจสอบของตนเองที่ประมวลผลด้วยแผนข้างเตียง แนวทางการรายงานแบบจำลองทำนายขอให้ผู้เขียนอธิบายว่าจัดการข้อมูลที่หายไปอย่างไร [8]

ความเข้าใจผิดที่พบบ่อย

  • "ห้ามใช้ผลลัพธ์เติมตัวทำนาย" หรือคำที่เป็นภาพสะท้อนของมัน "ให้ใส่ผลลัพธ์ในทุกแบบจำลองสำหรับเติมค่าเสมอ"

    ทั้งสองแบบมองข้ามขั้นของแบบจำลอง ในการพัฒนา การไม่ใส่ delirium ดึงสัมประสิทธิ์ของ albumin ไปที่ -0.0670 เทียบกับ -0.1014 ในการวิเคราะห์เฉพาะรายที่ข้อมูลครบ ที่ข้างเตียง การใส่ผลลัพธ์เป็นไปไม่ได้เพราะมันยังไม่เกิดขึ้น

    วิธีแก้: ทำตามกฎที่แก้แล้วข้างต้น หนึ่งข้อย่อยต่อหนึ่งขั้น: การพัฒนา การตรวจสอบความถูกต้อง และผู้ป่วยรายใหม่ที่ข้างเตียง

  • การใส่ผลลัพธ์ทำให้ข้อมูลขาดหายแบบสุ่มสมบูรณ์

    MCAR อธิบายว่าข้อมูลหายไปอย่างไร ไม่มีทางเลือกใดในแบบจำลองสำหรับเติมค่าที่เปลี่ยนมันได้ภายหลัง

    วิธีแก้: ถามว่าอะไรเป็นตัวขับให้ข้อมูลหาย และใส่ทุกตัวแปรที่บันทึกไว้ซึ่งทำนายการหายไปในแบบจำลองสำหรับเติมค่า ตามที่ MAR ต้องการ

  • การใส่ผลลัพธ์เปลี่ยน MNAR ให้เป็น MAR

    ผลลัพธ์อาจอธิบายการหายไปได้บางส่วน แต่การขึ้นกับค่าที่ไม่ได้บันทึกเองยังคงอยู่

    วิธีแก้: ถ้า MNAR เป็นไปได้ ให้ทำการวิเคราะห์ความไวที่เลื่อนค่าที่เติมไปตามจำนวนที่กำหนด และรายงานว่าค่าประมาณเปลี่ยนไปอย่างไร

  • เมื่อใส่ผลลัพธ์ในแบบจำลองสำหรับเติมค่าแล้ว ชุดข้อมูลที่เติมครบชุดเดียวก็เพียงพอ

    ชุดข้อมูลที่เติมครบชุดเดียวปฏิบัติต่อค่าที่เดาเหมือนค่าที่วัดได้ ค่าคลาดเคลื่อนมาตรฐานจึงเล็กเกินไป ไม่ว่าแบบจำลองสำหรับเติมค่าจะมีอะไรอยู่

    วิธีแก้: สร้างชุดข้อมูลที่เติมหลายชุดแล้วรวมผลด้วยกฎของ Rubin

  • การเติมช่องว่างแต่ละช่องด้วยค่าที่แบบจำลองทำนาย (fitted mean) เหมือนกับการเติมค่าที่หายไปหลายครั้ง

    ค่าที่แบบจำลองทำนายอยู่บนเส้นถดถอย ทำให้ความแปรปรวนของตัวแปรหดลง และซ่อนความไม่แน่นอนของแต่ละค่าที่หายไป

    วิธีแก้: สำหรับการประมาณ ให้สุ่มค่าที่เติมพร้อมความไม่แน่นอนของมัน เช่นด้วย predictive mean matching ส่วนการเติมค่าด้วยการถดถอยครั้งเดียวเก็บไว้ใช้ที่ข้างเตียง ซึ่งต้องการหนึ่งการทำนายต่อผู้ป่วยหนึ่งราย

  • แบบจำลองสำหรับเติมค่าที่สมบูรณ์พร้อมจะระบุผิดไม่ได้

    มันยังใช้รูปแบบฟังก์ชันผิดได้ ละปฏิกิริยาสัมพันธ์ที่แบบจำลองสำหรับวิเคราะห์มีได้ หรือสมมติการแจกแจงผิดได้

    วิธีแก้: ให้แบบจำลองสำหรับเติมค่ามีพจน์เหมือนแบบจำลองสำหรับวิเคราะห์ และเปรียบเทียบค่าที่เติมกับค่าที่บันทึกไว้

  • "การใช้ผลลัพธ์เติมตัวทำนายระหว่างการพัฒนาทำให้ AUROC ของการตรวจสอบความถูกต้องสูงเกินจริง"

    ในทะเบียนจำลอง AUROC ของชุดตรวจสอบเท่ากับ 0.8777, 0.8776 และ 0.8777 ในสามกลยุทธ์ โดยแต่ละแบบจำลองถูกให้คะแนนในข้อมูลตรวจสอบที่ albumin บันทึกครบ การเติมตัวทำนายของชุดตรวจสอบที่หายไปด้วยผลลัพธ์ของชุดตรวจสอบเป็นอีกขั้นหนึ่ง และเป็นที่ที่การรั่วไหลอยู่ เช่นเดียวกับการเติมค่าครั้งเดียวก่อนสุ่มซ้ำ

    วิธีแก้: ตัดสินการรั่วไหลทีละขั้น และรายงานความสามารถในการจำแนกและ calibration จากข้อมูลตรวจสอบที่ประมวลผลแบบเดียวกับที่เครื่องมือข้างเตียงจะประมวลผลผู้ป่วยรายใหม่

สิ่งที่ควรทำในการวิเคราะห์ของคุณเอง

อภิธานศัพท์

analysis model
แบบจำลองที่ตอบคำถามของการศึกษา ในที่นี้คือการถดถอยโลจิสติกของ delirium บน albumin และตัวทำนายอีกหกตัว
imputation model
แบบจำลองช่วยงานที่ใช้สร้างค่าที่สมเหตุสมผลสำหรับตัวแปรในที่ที่ค่าหายไปเท่านั้น
multiple imputation (MI)
การเติมค่าที่หายไปหลายครั้ง คือการเติมค่าที่หายไปแต่ละค่าหลายครั้งจากการแจกแจงเชิงทำนาย แล้วรวมผลการวิเคราะห์ด้วยกฎของ Rubin
predictive mean matching (PMM)
การจับคู่ค่าเฉลี่ยทำนาย คือวิธีเติมค่าที่ยืมค่าที่สังเกตได้จากผู้ให้ค่าที่มีค่าเฉลี่ยทำนายใกล้เคียงกัน
Rubin's rules
สูตรที่รวมค่าประมาณข้ามชุดข้อมูลที่เติมแล้ว โดยบวกความแปรปรวนระหว่างชุดข้อมูลเข้ากับความแปรปรวนภายในชุดข้อมูล
complete-case analysis
การวิเคราะห์ที่จำกัดเฉพาะผู้ป่วยที่ไม่มีค่าที่หายไปในตัวแปรของแบบจำลอง
reference value
ค่าอ้างอิง ในที่นี้คือสัมประสิทธิ์ของ albumin ที่แบบจำลองช่วยงานเดียวกันให้เมื่อปรับกับประชากรจำลองอีกชุดหนึ่งที่มีขนาดใหญ่มากและบันทึก albumin ครบทุกค่า ซึ่งเป็นเป้าหมายที่ใช้เทียบทั้งสามวิธี
regression imputation
การเติมค่าด้วยการถดถอย คือการเติมช่องว่างแต่ละช่องครั้งเดียวด้วยค่าที่ทำนายจากตัวทำนายอื่น โดยใช้แบบจำลองที่ปรับจากข้อมูลพัฒนาโดยไม่ใช้ผลลัพธ์ เหมาะกับการทำนายหนึ่งครั้งต่อผู้ป่วยหนึ่งรายที่ข้างเตียง ไม่เหมาะกับการประมาณสัมประสิทธิ์
data leakage
การรั่วไหลของข้อมูล คือการใช้ข้อมูลระหว่างสร้างหรือประเมินแบบจำลองที่จะไม่มีตอนนำแบบจำลองไปใช้
internal validation
การตรวจสอบความถูกต้องภายใน คือการประเมินประสิทธิภาพในผู้ป่วยรายใหม่จากแหล่งเดียวกัน ด้วยตัวอย่าง bootstrap ร่วมกับการแก้ค่า optimism หรือด้วย cross-validation
bootstrap sample
ตัวอย่าง bootstrap คือชุดข้อมูลใหม่ขนาดเท่าเดิมที่ได้จากการสุ่มผู้ป่วยซ้ำแบบใส่คืน ผู้ป่วยบางรายจึงปรากฏมากกว่าหนึ่งครั้งและบางรายไม่ปรากฏเลย
optimism correction
การแก้ค่า optimism คือการหักส่วนต่างเฉลี่ยที่แบบจำลองซึ่งปรับใหม่ในตัวอย่าง bootstrap ได้คะแนนในตัวอย่างของตัวเองสูงกว่าในข้อมูลเดิม ออกจากประสิทธิภาพที่วัดได้บนข้อมูลพัฒนา
cross-validation
การตรวจสอบไขว้ คือการแบ่งข้อมูลออกเป็นหลายส่วนที่เรียกว่า fold ปรับแบบจำลองโดยกันไว้หนึ่ง fold ให้คะแนน fold นั้น แล้วทำซ้ำจนทุก fold ถูกกันไว้ครบคนละหนึ่งครั้ง
external validation
การตรวจสอบความถูกต้องภายนอก คือการทดสอบแบบจำลองที่สร้างเสร็จแล้วในตัวอย่างแยกต่างหาก เช่น โรงพยาบาลอื่นหรือช่วงเวลาหลังจากนั้น
deployment
การนำไปใช้ คือการใช้แบบจำลองที่สร้างเสร็จแล้วทำนายผู้ป่วยจริง ซึ่งยังไม่ทราบผลลัพธ์
discrimination
ความสามารถในการจำแนก คือความสามารถของแบบจำลองในการจัดให้ผู้ป่วยที่เกิดผลลัพธ์มีความเสี่ยงทำนายสูงกว่าผู้ที่ไม่เกิด มักสรุปด้วย AUROC
AUROC
พื้นที่ใต้กราฟ receiver operating characteristic: ความน่าจะเป็นที่ผู้ป่วยที่มีผลลัพธ์ซึ่งสุ่มมาหนึ่งรายจะได้ความเสี่ยงทำนายสูงกว่าผู้ป่วยที่ไม่มีผลลัพธ์
calibration
ความสอดคล้องระหว่างความเสี่ยงที่แบบจำลองทำนายกับความเสี่ยงที่สังเกตได้ในผู้ป่วยกลุ่มเดียวกัน
calibration slope
ความชันของ calibration คือความชันของการถดถอยโลจิสติกของผลลัพธ์ที่สังเกตได้บนตัวทำนายเชิงเส้นของแบบจำลอง ค่า 1 หมายความว่าค่าทำนายไม่สุดโต่งเกินไปและไม่ค่อนกลางเกินไป
calibration-in-the-large (CITL)
ความสอดคล้องโดยรวมของระดับความเสี่ยง คือจุดตัดแกนของการถดถอยนั้นเมื่อกำหนดความชันไว้ที่ 1 ค่า 0 หมายความว่าความเสี่ยงทำนายเฉลี่ยเท่ากับความเสี่ยงที่สังเกตได้
MCAR
ข้อมูลขาดหายแบบสุ่มสมบูรณ์ (missing completely at random): การหายไปที่ไม่เกี่ยวกับข้อมูลใดทั้งที่สังเกตได้และสังเกตไม่ได้
MAR
ข้อมูลขาดหายแบบสุ่ม (missing at random): การหายไปที่ขึ้นกับข้อมูลที่สังเกตได้เท่านั้น
MNAR
ข้อมูลขาดหายแบบไม่สุ่ม (missing not at random): การหายไปที่ขึ้นกับค่าที่สังเกตไม่ได้เอง

เอกสารอ้างอิง

  1. White IR, Royston P, Wood AM. Multiple imputation using chained equations: issues and guidance for practice. Stat Med. 2011;30(4):377-399. https://doi.org/10.1002/sim.4067
  2. Moons KGM, Donders RART, Stijnen T, Harrell FE Jr. Using the outcome for imputation of missing predictor values was preferred. J Clin Epidemiol. 2006;59(10):1092-1101. https://doi.org/10.1016/j.jclinepi.2006.01.009
  3. Sterne JAC, White IR, Carlin JB, Spratt M, Royston P, Kenward MG, Wood AM, Carpenter JR. Multiple imputation for missing data in epidemiological and clinical research: potential and pitfalls. BMJ. 2009;338:b2393. https://doi.org/10.1136/bmj.b2393
  4. Hoogland J, van Barreveld M, Debray TPA, et al. Handling missing predictor values when validating and applying a prediction model to new patients. Stat Med. 2020;39(25):3591-3607. https://doi.org/10.1002/sim.8682
  5. White IR, Royston P. Imputing missing covariate values for the Cox model. Stat Med. 2009;28(15):1982-1998. https://doi.org/10.1002/sim.3618
  6. Meng XL. Multiple-imputation inferences with uncongenial sources of input. Stat Sci. 1994;9(4):538-558. https://doi.org/10.1214/ss/1177010269
  7. Sisk R, Sperrin M, Peek N, van Smeden M, Martin GP. Imputation and missing indicators for handling missing data in the development and deployment of clinical prediction models: a simulation study. Stat Methods Med Res. 2023;32(8):1461-1477. https://doi.org/10.1177/09622802231165001
  8. Collins GS, Moons KGM, Dhiman P, Riley RD, Beam AL, Van Calster B, et al. TRIPOD+AI statement: updated guidance for reporting clinical prediction models that use regression or machine learning methods. BMJ. 2024;385:e078378. https://doi.org/10.1136/bmj-2023-078378

ประเด็นสำคัญ

  • แบบจำลองสำหรับวิเคราะห์ตอบคำถามทางคลินิก ส่วนแบบจำลองสำหรับเติมค่าเพียงเติมช่องว่าง เมื่อประมาณสัมประสิทธิ์ ไม่ว่าในการพัฒนาแบบจำลองหรือการวิเคราะห์เชิงเหตุผล แบบจำลองสำหรับเติมค่าทำงานได้ดีที่สุดเมื่อมีทุกตัวแปรของแบบจำลองสำหรับวิเคราะห์ รวมทั้งผลลัพธ์ ส่วนในการตรวจสอบความถูกต้องและที่ข้างเตียง บทบาทของผลลัพธ์เปลี่ยนไป (ดูกฎที่แก้แล้ว)
  • ในทะเบียนจำลอง การไม่ใส่ delirium ในแบบจำลองสำหรับเติมค่าดึงสัมประสิทธิ์ของ albumin ไปที่ -0.0670 เทียบกับ -0.1014 สำหรับการวิเคราะห์เฉพาะรายที่ข้อมูลครบ -0.0991 เมื่อใส่ delirium และค่าอ้างอิง -0.0962
  • AUROC และ calibration ของการตรวจสอบความถูกต้องแทบไม่เปลี่ยนในสามกลยุทธ์ ความสามารถในการจำแนกและ calibration ที่คงที่จึงอาจซ่อนสัมประสิทธิ์ที่ถูกดึงเข้าหาศูนย์ และคะแนนที่นำไปใช้จริงยังต้องตรวจทั้งสองอย่างภายใต้แผนจัดการค่าที่หายไปข้างเตียง
  • การรั่วไหลเป็นคำถามเรื่องว่ามีข้อมูลหรือไม่: delirium เป็นที่รู้ระหว่างการพัฒนา ในการตรวจสอบความถูกต้องภายนอกมันใช้ให้คะแนนค่าทำนาย และเมื่อเป้าหมายคือประสิทธิภาพในการใช้งานจริง ก็ไม่ใช้เติมตัวทำนายของชุดตรวจสอบ ส่วนที่ข้างเตียงมันยังไม่เกิดขึ้น
  • การใส่ผลลัพธ์เปลี่ยนกลไกการหายไปของข้อมูลไม่ได้ เพราะ MCAR, MAR และ MNAR อธิบายว่าข้อมูลหายไปอย่างไร

อ่านต่อในวิกิ (บทความภาษาอังกฤษ): [[missing-data-mechanisms]] [[multiple-imputation-clinical-research]]

0
ถึงนักอ่านชาวไทยและต่างชาติทำความเข้าใจบริบททางการแพทย์ของผมอ่านต่อ →ถึงนักอ่านชาวไทยและต่างชาติทำความเข้าใจเนื้อหาของผมที่นอกเหนือจากการแพทย์อ่านต่อ →

ความคิดเห็น

ยังไม่มีความคิดเห็น มาเป็นคนแรกกันเลย

เข้าสู่ระบบเพื่อแสดงความคิดเห็น