- ผู้เขียนอธิบายปัญหาของ NumPy พร้อมตัวอย่างหลายกรณี
- การคำนวณอาร์เรย์แบบง่ายทำได้สะดวกด้วย NumPy แต่เมื่อจำนวนมิติเพิ่มขึ้น ความซับซ้อนและความสับสนก็เพิ่มขึ้นอย่างรวดเร็ว
- การออกแบบของ NumPy เช่น broadcasting และ advanced indexing ยังขาดความชัดเจนและการทำ abstraction ที่ดี
- การเขียนโค้ดจึงต้องพึ่งการเดาและการลองผิดลองถูก แทนที่จะระบุแกนอย่างชัดเจน
- ผู้เขียนเสนอแนวคิดเกี่ยวกับภาษาอาร์เรย์ที่ปรับปรุงแล้ว และจะนำเสนอทางเลือกที่เป็นรูปธรรมในบทความถัดไป
บทนำ: ทั้งรักทั้งเกลียด NumPy
- ผู้เขียนระบุว่าใช้ NumPy มานาน แต่ก็ผิดหวังกับข้อจำกัดของมันอย่างมาก
- NumPy เป็นไลบรารีสำคัญและทรงอิทธิพลสำหรับการคำนวณอาร์เรย์ใน Python
- แม้แต่ ไลบรารีแมชชีนเลิร์นนิง สมัยใหม่อย่าง PyTorch ก็ยังมีปัญหาคล้าย NumPy อยู่ด้วย
สิ่งที่ง่ายและสิ่งที่ยากใน NumPy
- การคำนวณง่าย ๆ เช่นการแก้สมการเชิงเส้นพื้นฐาน ทำได้ด้วย ไวยากรณ์ที่ชัดเจนและสวยงาม
- แต่เมื่อมิติของอาร์เรย์สูงขึ้นหรือการคำนวณซับซ้อนขึ้น ก็จำเป็นต้องประมวลผลแบบเป็นชุดโดยไม่ใช้ for loop
- ในสภาพแวดล้อมที่ใช้ลูปไม่ได้ เช่นการคำนวณบน GPU จำเป็นต้องใช้ ไวยากรณ์ vectorization แบบเฉพาะทาง หรือรูปแบบการเรียกฟังก์ชันพิเศษ
- แต่ วิธีใช้งานที่ถูกต้อง ของฟังก์ชันเหล่านี้กลับคลุมเครือ และยากจะเข้าใจให้ชัดเจนจากเอกสารเพียงอย่างเดียว
- ตัวอย่างเช่น ในกรณีอาร์เรย์หลายมิติ ก็แทบไม่มีใครมั่นใจนักว่าควรใช้ฟังก์ชัน
linalg.solve ของ numpy อย่างไรจึงจะถูกต้อง
ปัญหาของ NumPy
- NumPy ขาดทฤษฎีที่สอดคล้องกันสำหรับการนำการคำนวณไปใช้กับบางส่วนหรือบางแกนของ อาร์เรย์หลายมิติ
- เมื่อมิติของอาร์เรย์ไม่เกิน 2 ทุกอย่างยังค่อนข้างชัดเจน แต่พอเป็น 3 มิติขึ้นไป การระบุว่าแต่ละอาร์เรย์จะคำนวณกับแกนใดกลับไม่ชัดเจน
- จึงบังคับให้ใช้วิธีซับซ้อนอย่าง None, broadcasting,
np.tensordot เพื่อให้มิติตรงกันอย่างชัดแจ้ง
- วิธีเหล่านี้ทำให้เกิดความผิดพลาดได้ง่าย ลดความอ่านง่ายของโค้ด และเพิ่มโอกาสเกิดบั๊ก
ลูปกับความชัดเจน
- ในความเป็นจริง ถ้าอนุญาตให้ใช้ลูป ก็สามารถเขียนโค้ดที่ กระชับและชัดเจนกว่า ได้
- แม้โค้ดแบบลูปอาจดูไม่หรูหราเท่าไร แต่ในแง่ความชัดเจนกลับมีข้อดีมาก
- ตรงกันข้าม หากมิติของอาร์เรย์เปลี่ยนไป ก็ต้องคอยกังวลเรื่อง transpose หรือการจัดลำดับแกนทีละจุด ทำให้ความซับซ้อนเพิ่มขึ้น
np.einsum: ฟังก์ชันที่ดีเป็นพิเศษ
np.einsum ทรงพลังเพราะให้ภาษาเฉพาะโดเมนที่ยืดหยุ่นสำหรับการตั้งชื่อแกน
- einsum ทำให้เจตนาของการคำนวณชัดเจนและยัง generalized ได้ดี จึงสามารถเขียนการคำนวณแกนที่ซับซ้อนได้อย่าง explicit
- แต่แนวทางแบบ einsum กลับรองรับเพียง การคำนวณบางประเภท เท่านั้น และใช้กับ
linalg.solve ไม่ได้ เป็นต้น
ปัญหาของ broadcasting
- broadcasting ซึ่งเป็นกลไกหลักของ NumPy คือความสามารถในการปรับมิติให้เข้ากันโดยอัตโนมัติเมื่อมิติไม่ตรงกัน
- ในกรณีง่าย ๆ มันสะดวก แต่ในทางปฏิบัติมักทำให้ยากต่อการเข้าใจมิติอย่างชัดเจน และมีกรณีผิดพลาดอยู่มาก
- เพราะ broadcasting เป็นกลไกโดยนัย เวลาอ่านโค้ดจึงต้องคอยตรวจสอบทุกครั้งว่าการคำนวณทำงานอย่างไร
ความไม่ชัดเจนของการทำ indexing
- advanced indexing ของ NumPy ทำให้การคาดเดา shape ของอาร์เรย์เป็นเรื่องยากและไม่ชัดเจนมาก
- shape ของอาร์เรย์ผลลัพธ์จะเปลี่ยนไปตามการผสมรูปแบบ indexing ที่ใช้ จึงคาดเดาได้ยากหากไม่มีประสบการณ์ตรง
- แม้แต่เอกสารอธิบายกฎของ indexing ก็ยังยาวและซับซ้อน ทำให้เสียเวลาศึกษาอย่างมาก
- ต่อให้ตั้งใจจะใช้เพียง indexing แบบง่าย ในบางการคำนวณก็หลีกเลี่ยงการใช้ advanced indexing ไม่ได้
ข้อจำกัดของการออกแบบฟังก์ชัน NumPy
- ฟังก์ชันจำนวนมากของ NumPy ถูกออกแบบมาให้เหมาะกับ shape ของอาร์เรย์บางแบบเท่านั้น
- สำหรับอาร์เรย์หลายมิติ จำเป็นต้องใช้อาร์กิวเมนต์ axes เพิ่มเติม ใช้ชื่อฟังก์ชันแยก หรืออาศัยขนบเฉพาะ ซึ่งแต่ละฟังก์ชันก็ไม่สอดคล้องกัน
- โครงสร้างแบบนี้สวนทางกับหลักการเขียนโปรแกรมที่ให้ความสำคัญกับ abstraction และการนำกลับมาใช้ซ้ำ
- แม้จะมีฟังก์ชันแก้ปัญหาเฉพาะหน้าได้ แต่หากต้องนำกลับไปใช้กับอาร์เรย์และแกนแบบอื่น ก็ต้องเขียนโค้ดใหม่แทบทั้งหมด
ตัวอย่างจริง: การทำ self-attention
- เมื่อต้องเขียน self-attention ด้วย NumPy หากใช้ลูปโค้ดจะชัดเจน แต่ถ้าถูกบังคับให้ทำ vectorization โค้ดจะซับซ้อนขึ้นมาก
- โดยเฉพาะเมื่อเป็น multi-head attention ที่ต้องใช้การคำนวณหลายมิติ ก็ต้องผสมทั้ง einsum และการแปลงแกนเข้าด้วยกัน ทำให้โค้ดเข้าใจยาก
บทสรุปและทางเลือก
- ผู้เขียนระบุว่า NumPy เป็น "ตัวเลือกเดียวที่ทั้งมีจุดด้อยมากกว่า array language อื่น ๆ แต่ก็กลายเป็นสิ่งสำคัญในตลาดมากที่สุด"
- เพื่อแก้ปัญหาต่าง ๆ ของ NumPy เช่น broadcasting, ความไม่ชัดเจนของ indexing, และความไม่สอดคล้องของฟังก์ชัน ผู้เขียนบอกเป็นนัยว่าได้สร้างต้นแบบของ ภาษาอาร์เรย์ที่ปรับปรุงแล้ว ขึ้นมา
- แนวทางปรับปรุงที่เป็นรูปธรรม (API ของภาษาอาร์เรย์แบบใหม่) มีแผนจะนำเสนอในบทความแยกภายหลัง
4 ความคิดเห็น
ฟังดูเหมือนเป็นเรื่องราวว่าทำไม Julia ถึงถือกำเนิดขึ้นนะครับ แม้จะต้องศึกษาไลบรารีต่าง ๆ แต่ในแง่ที่มันช่วยแก้ปัญหาหลายอย่างของ NumPy ได้ ก็ดูเป็นตัวเลือกที่น่าสนใจมากจริง ๆ ครับ
ถ้าใช้
vectorizationของ numpy ไม่เก่ง ประสิทธิภาพก็พังได้เลยครับ การต้องเขียนโดยคำนึงถึงเรื่องพวกนั้นทั้งเครียดและยากครับผมไม่ชอบ NumPy
ดูเหมือนไลบรารี Python ที่ค่อนข้างเก่า ๆ จะมีปัญหาคล้าย ๆ กันหมด
ความคิดเห็นจาก Hacker News
bจะอ่านเข้าใจยาก แต่เพราะมีคำอธิบายเกี่ยวกับ shape ที่คืนกลับมาด้วย จึงต้องตรวจสอบว่าเวกเตอร์bนั้นมีรูปเป็นเมทริกซ์จริงหรือไม่ (โดยเฉพาะกรณีK=1)da.sel(x=some_x).isel(t=-1).mean(["y", "z"])ทำได้ง่าย และการ broadcast ก็ชัดเจนเพราะเคารพชื่อมิติด้วย เด่นมากสำหรับงานข้อมูลภูมิสารสนเทศที่มีหลาย CRS และยังใช้งานร่วมกับ Arviz ได้ดีมาก ทำให้จัดการมิติเพิ่มเติมในงานวิเคราะห์แบบเบย์ได้ง่าย อีกทั้งยังรวมหลายอาร์เรย์ไว้ใน dataset เดียวและใช้พิกัดร่วมกันได้ จึงสั่งงานกับทุกอาร์เรย์ที่มีแกนเวลาได้ง่าย เช่นds.isel(t=-1)array[:, :, None]พอเห็นว่ามีคนคิดเหมือนกันก็ดีใจnp.linalg.solveเร็วที่สุดเสมอแล้วบังคับให้ทุกอย่างต้องเข้ารูปนั้นไม่ถูกต้อง เพราะยังมีอีกหลายเหตุผลที่การเขียน problem-specific kernel เองจะดีกว่า\\transpose/reshapeและการจัดรูปชนิดข้อมูลก็ไม่สม่ำเสมอจนคลุมเครือjaxกับvmapดูได้squeezeเป็นต้น ตัวปัญหาเองคลุมเครือจนแทบไม่เข้าใจว่ากังวลอะไรreshapeได้poly1dชื่อPแล้วคูณจากด้านขวาด้วยz0จะได้poly1dกลับมา แต่ถ้าคูณจากด้านซ้ายเป็นz0*Pจะได้เพียงอาร์เรย์ ทำให้เกิดการแปลงชนิดแบบเงียบ ๆ นอกจากนี้ค่าสัมประสิทธิ์นำของ quadratic ก็เข้าถึงได้ทั้งP.coef[0]และP[2]ซึ่งชวนให้สับสน ทางการบอกว่าpoly1dเป็น API แบบ “เก่า” และแนะนำให้โค้ดใหม่ใช้คลาสPolynomialแต่ในความเป็นจริงก็ไม่มีแม้แต่คำเตือน deprecated กับดักอย่างการแปลงชนิดและความไม่สอดคล้องของ datatype แบบนี้มีอยู่ทั่วทั้งไลบรารีจนกลายเป็นฝันร้ายเวลา debugto_numpy()สุดท้ายจึงเสียเวลากับการแปลง format ข้อมูลมากกว่าการแก้ปัญหาจริง Julia เองก็ไม่ได้มีแต่ข้อดี แต่การเชื่อมต่อกันระหว่างไลบรารีหลายตัว เช่น เรื่องหน่วยและความไม่แน่นอน ทำได้ดีกว่า ส่วน Python มักต้องเขียน boilerplate เยอะเสมอnumpysaneสุดท้ายก็ยังเป็น Python loop ไม่ใช่ vectorization จริงeinsumเพื่อทำ vectorized multi-head attention และใช้optimize="optimal"ให้เลือกอัลกอริทึม matrix chain multiplication เพื่อเร่งความเร็ว ผลคือเร็วขึ้นจาก implementation แบบ vectorized ทั่วไปราว 2 เท่าจริง แต่ที่น่าประหลาดคือ implementation แบบตรงไปตรงมาที่ใช้ loop กลับเร็วกว่า ใครอยากรู้เหตุผลลองดูโค้ดได้ คาดว่ายังมีพื้นที่ให้ปรับปรุง cache coherency ภายในeinsumได้อีก