มาสร้าง Inception โดยใช้ PyTorch กัน

Thana Hongsuwan
15 min readAug 26, 2024

--

บทความนี้เป็นบทความต่อเนื่อง ในซีรีย์ “สร้างโมเดล โดย PyTorch” ซึ่งบทความก่อนหน้านี้ ก็ได้ทดลองสร้าง LeNet5, AlexNet และ VGG-16 กันไปแล้ว สำหรับในบทความนี้เราจะสร้าง Inception ซึ่งเป็นโมเดลที่เสนอโดย Google และชนะในการแข่งขัน ImageNet ILSVRC ในปี 2014 ดังนั้นต้องมีอะไรใหม่ๆ อย่างแน่นอน

เราจะเริ่มด้วยการสำรวจและทำความเข้าใจกับสถาปัตยกรรมของ Inception จากนั้นก็จะเริ่มเขียนโปรแกรมกัน โดยบทความนี้จะยังคงใช้ dataset CIFAR10 เหมือนเดิม เพื่อจะได้เปรียบเทียบความสามารถเมื่อเทียบกับ VGG-16 ในบทความที่ผ่านมา

Inception

Inception network เปิดตัวในปี 2014 โดยกลุ่มนักวิจัยที่ Google ซึ่งได้ตีพิมพ์บทความชื่อ “Going Deeper with Convolutions” จุดสำคัญหลักของงานนี้ คือ สร้าง neural network ที่มีความลึกมากขึ้น ในขณะที่ปรับปรุงการใช้งานทรัพยากรการคำนวณภายในเครือข่ายไปด้วย โครงสร้างที่ใช้แข่งขัน ImageNet ILSVRC 2014 มีชื่อเรียกว่า GoogLeNet มีความลึกถึง 22 ชั้น (ในขณะที่ VGG-16 มีความลึกเพียง 16 ชั้นเท่านั้น) และที่น่าสนใจคือในขณะที่เพิ่มจำนวนความลึก แต่กลับลดจำนวนพารามิเตอร์ลงได้ โดยลดลงได้มากถึง 12 เท่า (จาก ~138 ล้านเป็น ~13 ล้าน) และยังให้ผลลัพธ์ที่แม่นยำอย่างมากๆ อีกด้วย ชักสนใจแล้วใช่มั้ยครับว่าทำได้อย่างไร

จุดที่สำคัญที่สุดของสถาปัตยกรรม Inception ก็คือ สิ่งที่เรียกว่า Inception Module ซึ่งถือได้ว่าเป็นนวัตกรรมที่ปฏิวัติวงการครั้งใหญ่ แล้วมันคืออะไรล่ะ ค่อยๆ มาดูแรงจูงใจทีละประเด็นกัน

  • kernel size หรือขนาดของ kernel ซึ่งจากบทความที่ผ่านๆ มาซึ่งอธิบายสถาปัตยกรรมของ deep learning ที่มีมาก่อน Inception จะเห็นว่าขนาดของ kernel ที่ใช้มีหลากหลายขนาด 1 × 1, 3 × 3, 5 × 5 และที่มากที่สุด คือ 11x11 ใน AlexNet ซึ่งในการออกแบบ convolutional layer นั้นผู้พัฒนาจะต้องทดลองเพื่อหาว่าจะใช้ kernel ขนาดใดดี จึงจะได้ผลดีที่สุด (กับ dataset นั้น) โดยทั่วไปแล้ว kernel ที่มีขนาดเล็กจะสามารถเก็บรายละเอียดได้ดีกว่า แต่ kernel ขนาดใหญ่ๆ จะทำงานได้เร็วกว่า เนื่องจากจำนวนครั้งที่ทำจะน้อยลง การเลือกขนาดของ kernel จึงเป็นงานที่ยากอย่างหนึ่งในการออกแบบ
  • Pooling Layer เราทราบกันดีว่าประโยชน์ของ pooling layer คือ ทำให้ข้อมูลมีขนาดเล็กลง แต่เราไม่สามารถใส่ pooling layer มากมายได้ เนื่องจากจะทำให้ข้อมูลที่ได้มีขนาดเล็กเกินกว่าจะเก็บ feature ของภาพได้ ดังนั้นคำถาม คือ จะมี pooling layer มากแค่ไหน และ ใส่ไว้ที่ใดบ้าง ขอยกตัวอย่าง VGG-16 ที่ใส่ pooling layer เอาไว้ 5 ตำแหน่ง โดย 2 ตำแหน่งแรกใส่ทุก 2 convolutional layer และ 3 ตำแหน่งหลังใส่ทุก 3 convolutional layer

การเลือก kernel size และตำแหน่งของ pooling layer มักจะเป็นการตัดสินใจจากการลองผิดลองถูก (trial and error) และทดลองซ้ำแล้วซ้ำเล่า เพื่อหาผลลัพธ์ที่ดีที่สุด ตรงนี้เองที่ ผู้เสนอโมเดล Inception คิดว่า “แทนที่จะต้องมาเลือก kernel size และตำแหน่งของ pooling layer เราใส่ทุกๆ แบบไว้ใน block เดียวกันไปเลยจะดีหรือไม่” และ block นี้เองที่มีชื่อเรียกว่า inception module

Inception module

โครงสร้างของการใช้ inception module เทียบกับโครงสร้างของ CNN แบบดั้งเดิม แสดงตามภาพ

Classical convolutional networks vs. the Inception network

จากภาพจะสังเกตุว่าในโครงสร้าง CNN แบบดัังเดิม (เช่น LeNet, AlexNet และ VGGNet) นั้น จะมีการซ้อน convolutional และ pooling layer กันไปเรื่อยๆ ตามการออกแบบของแต่ละโมเดล เพื่อจะสกัด feature ออกมา และ เมื่อถึงท้ายสุดก็ค่อยเอาชั้น Fully connected layer มาต่อเพื่อทำหน้าที่เป็น classifier แยกประเภท

แต่สำหรับสถาปัตยกรรม Inception จะเริ่มด้วย convolutional layer และ pooling
layer เหมือนกัน จากนั้นจะซ้อนด้วย inception module กับ pooling layer และ
inception modules กับ pooling layer ซ้อนอีกทีหนึ่ง เพื่อใช้ในการสกัด feature จากนั้น ก็ค่อยเข้า Fully connected layer เพื่อแยกประเภทอีกครั้ง

แล้ว Inception module คืออะไรล่ะ เพื่อให้เข้าใจการทำงาน เราจะลองมาแกะดูว่าข้างในกล่อง Inception module ประกอบด้วยอะไรบ้าง และมันทำงานอย่างไร

ผู้ออกแบบ Inception คิดว่า แทนที่จะเลือกขนาดของ kernel เป็นขนาดโน้น ขนาดนี้ทำไมถึงไม่เก็บข้อมูลครั้งละหลายๆ ขนาดไปพร้อมๆ กันซะเลย ดังนั้นเขาจึงออกแบบให้ Inception Module มี 4 Layer ที่ขนานกันไปดังรูปข้างบน (ควรเข้าใจว่ากว่าจะออกมาเป็น design นี้ น่าจะต้องผ่านการทดลองมาแล้ว ไม่ใช่อยู่ๆ ก็คิดออกมาได้)

โดย 4 layer มีดังนี้

  • 1 × 1 convolutional layer
  • 3 × 3 convolutional layer
  • 5 × 5 convolutional layer
  • 3 × 3 max-pooling layer

โดยผลลัพธ์การทำงานของทั้ง 4 layer นี้แทนที่จะส่งต่อกันเป็นชั้นๆ เหมือนกับ CNN Model ก่อนหน้านี้ กลับนำมาประกบด้านข้างกัน แล้วส่งออกมาเป็น output เพียงก้อนเดียว เพื่อส่งให้ชั้นต่อไป

มาลงรายละเอียดกันหน่อย สมมติว่า ข้อมูลที่เข้าสู่ Inception Module นี้ เป็น feature ขนาด 32x32 จำนวน 200 แผ่น (32 × 32 × 200) เราจะนำข้อมูลทั้ง 32x32x200 เข้าไปที่ Inception Module ซึ่งจะถูกส่งเข้าไปใน layer ย่อยทั้ง 4 ของ Inception Module พร้อมๆ กัน

  • ที่ 1 x 1 convolutional layer ที่มีความลึก หรือ จำนวนแผ่น = 64 และ padding = same (หมายถึงให้ผลลัพธมีขนาด 32x32 เท่ากับ input) ดังนั้น output จะเท่ากับ 32 × 32 × 64
  • ที่ 3 x 3 convolutional layer ที่มีความลึก หรือ จำนวนแผ่น = 128 และ padding = same (หมายถึงให้ผลลัพธมีขนาด 32x32 เท่ากับ input) ดังนั้น output จะเท่ากับ 32 × 32 × 128
  • ที่ 5 x 5 convolutional layer ที่มีความลึก หรือ จำนวนแผ่น = 32 และ padding = same (หมายถึงให้ผลลัพธมีขนาด 32x32 เท่ากับ input) ดังนั้น output จะเท่ากับ 32 × 32 × 32
  • ที่ 3 x 3 max-pooling layer โดยใช้ padding = same และ strides = 1 จะทำให้ได้ output 32 × 32 × 32

จะเห็นว่าผลลัพธ์ของทั้ง 4 block มีขนาด 32x32 ทั้งสิ้น จึงสามารถนำมาซ้อนต่อกันได้ ซึ่งจะทำให้ผลลัพธ์ทั้งหมด มีจำนวนแผ่น = 64+128+32+32 = 256 ดังนั้นผลลัพธ์ของ Inception Module จึงมีขนาด 32 x 32 x 256

การลดขนาดใน Inception module

จากขั้นตอนการทำงานของ Inception Module ที่ได้อธิบายไป อาจสังเกตุเห็นปัญหาใหญ่ประการหนึ่ง นั่นก็คือ ขนาดของการคำนวณใน module 5 x 5 ซึ่งเป็น kernel ที่มีขนาดใหญ่ที่สุด ทั้งนี้เนื่องจาก input ที่มีขนาด 32 × 32 × 200 เมื่อนำมาทำ convolution ด้วย kernel ขนาด 5 x 5 แต่ละจุดจะมีการคำนวณทั้งสิ้น = 5x5x32 = 800 ครั้ง โดยจำนวนจุดทั้งหมด คือ 32 x 32 x 200 = 204,800 จุด ดังนั้นการคำนวณทั้งหมด = 204,800 × 800 = 163,840,000 ครั้ง ซึ่งเป็นการคำนวณที่มหาศาลมาก

ดังนั้นผู้พัฒนาจึงเสนอวิธีการลดขนาดโดยนำ 1 x 1 convolutional layer มาใช้ โดยสามารถลดได้เหลือการคำนวณเพียง 1 ใน 10 ของการคำนวณเดิมเท่านั้น โดยจะเรียกชั้นนี้ว่า reduce layer หรือ bottleneck layer

แนวคิด คือ จะเพิ่ม 1 x 1 convolutional layer เข้าไปก่อนหน้า kernel ที่มีขนาดใหญ่ เช่น 3 × 3 และ 5 × 5 convolutional layer ดังรูป

จากรูปจะเห็นได้ว่ามีการเพิ่มกล่องสีเขียว ซึ่งก็คือ 1 x 1 convolutional layer โดยกำหนด kernel ขนาด 1 x 1 x 16 คือ มีจำนวนแผ่นเพียง 16 แผ่นเท่านั้น ดังนั้นจะได้ output ขนาด 32 x 32 x 16 บางคนอาจสงสัยว่า 1 x 1 convolutional มีการทำงานอย่างไร ในการทำงานของ 1 x 1 convolutional คือ จะนำตำแหน่งเดียวกันของ input เช่น ตำแหน่ง (0,0) ของทั้ง 200 แผ่น มาทำ convolutional กับ kernel ขนาด 1 x 1 ดังนั้นตำแหน่ง (0,0) ของทั้ง 200 แผ่น จะเหลือเพียง 1 ข้อมูลเท่านั้น

จากรูปแสดงการทำ 1 x 1 convolutional ระหว่าง input ที่มีขนาด 64 x 64 x 192 กับ 1 x 1 ดังนั้นจะเป็นการนำเอา kernel 1 x 1 ไปทำ convolutional กับแถบสีชมพูทั้งแถบ และจะได้ผลลัพธเป็นสีแดงเพียงตำแหน่งเดียว และ ทำเช่นนี้กับทุกตำแหน่ง ก็จะได้ผลลัพธ์ขนาด 64 x 64 จำนวน 1 แผ่น

สำหรับ Inception จะมี จำนวนแผ่น 16 แผ่น ดังนั้นจึงได้ output ขนาด 32 x 32 x 16 ซึ่งเมื่อพิจารณาถึงการคำนวนจะพบว่า มีการคำนวณ 2 ขั้นตอน คือ การทำ convolution 1 x 1 และ convolution 5 x 5 โดย convolution 1 x 1 จะใช้การคำนวณ (32 × 32 × 16) × (1 × 1 × 200) = 3.2 ล้านครั้ง และ convolution 3 x 3 จะใช้การคำนวณ (32 × 32 × 32) × (5 × 5 × 16) = 13.1 ล้านครั้ง รวมกันเท่ากับ 16.3 ล้านครั้ง ซึ่งเมื่อเทียบกับการคำนวณเดิม 163,840,000 ครั้งแล้ว เท่ากับเหลือเพียง 1 ใน 10 เท่านั้น ดังนั้นจึงคาดหวังได้ว่า Inception จะสามารถทำได้ได้ไม่ช้านัก

ผลกระทบของการลดขนาด

คุณอาจสงสัยว่าการลดพารามิเตอร์ของโมเดลไปมโหฬารขนาดนั้น คือ 90% จะไม่ทำให้ประสิทธิภาพของโมเดลเสียไปหรือ? ในเรื่องนี้ Szegedy และคณะซึ่งเป็นผู้พัฒนาของ google ได้ออกมาบอกว่าเขาได้ทดลองหลายต่อหลายครั้ง และพบว่าถ้าไม่ได้ลด layer มากเกินไป (อืม 90% ยังไม่มากเกินไป) เราสามารถลดขนาดได้โดย ไม่ทำให้ประสิทธิภาพเสียไปมากนัก

ในเรื่องนี้ต้องขอชมความกล้าในการลดพารามิเตอร์ และ ถ้าไม่ใช่บริษัทใหญ่แบบ google คงทำไม่ได้ เพราะผมเชื่อว่าตอนแรกคงไม่ได้ลดกันมโหฬารขนาดนี้ แต่เมื่อได้ทดลองไปแล้ว และพบว่าเมื่อลดขนาดพารามิเตอร์แล้ว ประสิทธิภาพยังดีอยู่ จึงลดลงไปเรื่อยๆ ซึ่งต้องอาศัยการทดลองหลายครั้งมาก ซึ่งถ้าไม่มีทรัพยากรมากมายแบบ google คงจะทำได้ยาก

ดังนั้น Inception Module ที่ทำการลดขนาดแล้ว สามารถเขียนได้ตามรูปด้านล่าง ซึ่งจะเห็นว่าได้มีการนำ 1 x 1 convolutional มาใช้ก่อนที่จะเข้า 3 x 3 convolutional และ 5 x 5 convolutional และนำมาใช้ต่อจากการทำ 3 x 3 max pooling ด้วย

การเพิ่มขั้นตอนการลดมิติข้อมูล ก่อนที่จะใช้ convolutional layer ขนาดใหญ่ ทำให้เราสามารถเพิ่มการทำงานในแต่ละขั้นตอนได้มาก โดยที่ไม่ทำให้ความซับซ้อนในการคำนวณเพิ่มขึ้นอย่างไร้การควบคุมในขั้นตอนต่อไป นอกจากนี้ยังทำให้สามารถแก้ปัญหาในการเลือกขนาดของ kernel เพราะข้อมูลจะได้รับการประมวลผลในหลายๆ สเกลไปพร้อมกัน จากนั้นจึงค่อยรวมเป็นข้อมูลเดียวกัน จะทำให้ในขั้นตอนต่อไปได้ข้อมูลที่เป็น abstract features ได้ดีขึ้น

โดยสรุปของการใช้ Inception Module คือ หากคุณกำลังสร้างโมเดล และไม่ต้องการเลือกว่าจะใช้ kernel ขนาดใดในแต่ละ layer หรือเพิ่ม pooling ตรงไหนบ้าง Inception Module เป็นทางเลือกหนึ่ง เพราะสามารถใช้ทุกตัวเลือกไปพร้อมๆ กัน นอกจากนั้น การใช้ 1 x 1 convolutional ยังสามารถลดขนาดของการคำนวณได้มาก

Inception architecture

เอาละ เมื่อเข้าใจถึง Inception Module ซึ่งเป็นหัวใจกันแล้ว เราก็พร้อมจะพูดถึงสถาปัตยกรรมของโครงข่ายนิวรอล Inception กันแล้ว เนื่องจากใน Inception Module ขนาดของข้อมูลที่เข้า และขนาดของข้อมูลที่ออก จะมีขนาดใกล้เคียงกัน หรือเท่ากัน ดังนั้นเราจึงสามารถวางซ้อน (stack) Inception Module มากแค่ไหนก็ได้ตามที่ต้องการ เพื่อสร้างโมเดลที่มีความลึกมากๆ โดยเพิ่ม 3 × 3 pooling layer เอาไว้ระหว่าง Module เพื่อ down sampling เท่านั้น ตามที่แสดงในรูป

ในการแข่งขัน ImageNet ILSVRC 2014 ทาง google ได้ส่ง Inception ที่มีโครงสร้างดังรูปด้านล่างเข้าแข่งขัน ตั้งชื่อว่า GoogLeNet โดยมีองค์ประกอบ 3 ส่วน โดยมีจำนวน Layer สูงถึง 22 ชั้น

  • ส่วนแรกเป็น CNN architecture คล้ายกับ AlexNet กับ LeNet
  • ส่วนที่สอง เป็น stack ของ Inception Module และ pooling layer
  • ส่วนที่ส่ม เป็น fully connected classifier เหมือนเดิม
GoogLeNet model

จากภาพจะเห็นได้ว่า GoogLeNet (เวอร์ชันหนึ่งของ Inception ที่ใช้แข่งขัน) จะใช้ Inception Module จำนวน 9 Module ทำการซ้อน (stack) ไว้ด้วยกันโดยมี max pooling layer เป็นระยะ

  • part A เหมือนกับสถาปัตยกรรม AlexNet และ LeNet คือ ประกอบด้วยลำดับของ convolutional layer และ pooling layer
  • part B ประกอบด้วย 9 Inception Module ซ้อนกันดังนี้ 2 Inception Module + pooling layer + 5 Inception Module + pooling layer + 2 Inception Module + pooling layer
  • part C fully connected layer ตามด้วย softmax layer ทำหน้าที่เป็น classifier

สร้าง GoogLeNet ด้วย PyTorch

ก่อนอื่นเราจะสร้าง Inception Module ก่อน ก็ขอนำรูป Inception Module มาลงอีกครั้ง จะได้ง่ายต่อการอธิบาย

Convolution Block

เราจะเริ่มจากสร้าง code ของ class เอนกประสงค์โดยให้ชื่อว่า ConvBlock โดย class นี้มีความหมายเท่ากับ 1 กล่องของภาพด้านบน โดยสามารถกำหนดให้ทำหน้าที่เป็นกล่องแบบไหนก็ได้ โดยมีพารามิเตอร์ของ class ดังนี้

  • in_channels เป็นจำนวน channel หรือจำนวนแผ่นที่ input เข้าสู่ block
  • out_channels เป็นจำนวน channel หรือจำนวนแผ่นของ output
  • kernel_size คือ ขนาดของ kernel ที่จะใช้ใน block นี้ เช่น ถ้าเป็น 1 ก็จะหมายถึง 1 x 1 ถ้าเป็น 3 หมายถึง 3 x 3 ถ้าเป็น 5 ก็หมายถึง 5x5
  • stride คือ ขนาดของ stride ที่จะใช้ โดยทั่วไปจะเป็น 1
  • padding คือ จำนวนข้อมูลที่เติมเพื่อให้ output มีขนาดเท่ากับ input ซึ่ง หากเป็น 1 x 1 จะใช้ padding 0 หากเป็น 3 x 3 จะใช้ padding 1 และหากเป็น 5 x 5 จะใช้ padding 2

ในคลาสนี้จะมีการทำงาน 3 อย่าง คือ 1) ทำ convolution 2D 2) ทำ batch normalization และ 3) ผ่าน ReLU

class ConvBlock(nn.Module):
"""
Creates a convolutional layer followed by batchNorm and relu. Bias is False as batchnorm will nullify it anyways.

Args:
in_channels (int) : input channels of the convolutional layer
out_channels (int) : output channels of the convolutional layer
kernel_size (int) : filter size
stride (int) : number of pixels that the convolutional filter moves
padding (int) : extra zero pixels around the border which affects the size of output feature map

Attributes:
Layer consisting of conv->batchnorm->relu

"""
def __init__(self, in_channels , out_channels , kernel_size , stride , padding , bias=False):
super(ConvBlock,self).__init__()

# 2d convolution
self.conv2d = nn.Conv2d(in_channels = in_channels, out_channels = out_channels, kernel_size = kernel_size, stride = stride, padding = padding , bias=False )

# batchnorm
self.batchnorm2d = nn.BatchNorm2d(out_channels)

# relu layer
self.relu = nn.ReLU()

def forward(self,x):
return self.relu(self.batchnorm2d(self.conv2d(x)))

Inception Block

เมื่อได้ ConvBlock ซึ่งแทนแต่ละกล่องในรูปของ Inception Module ต่อไปเราจะนำมาประกอบเป็น Inception Block จากรูปด้านบนจะเห็นว่าข้อมูลจะแยกออกเป็น 4 ทาง ซึ่งต่อไปเราจะเรียกว่า branch โดยแต่ละ branch มีรายละเอียดดังนี้

  • branch 1 : 1 x 1 convolution
  • branch 2 : 1 x 1 convolution ตามด้วย 3 x 3 convolution
  • branch 3 : 1 x 1 convolution ตามด้วย 5 x 5 convolution
  • branch 4 : 3 x 3 max pooling ตามด้วย 1 x 1 convolution

ต่อไปเราจะสร้างคลาส InceptionBlock ซึ่งจะรับข้อมูลดังต่อไปนี้

  • in_channels จำนวน channel หรือจำนวนแผ่นของ input
  • out_1x1 เป็นพารามิเตอร์ของ branch 1 ใช้กำหนดจำนวน channel output ของ branch 1
  • red_3x3 เป็นพารามิเตอร์ของ branch 2 ใช้กำหนดจำนวน channel output ของ 1x1 ที่อยู่ใน block แรกของ branch 2
  • out_3x3 เป็นพารามิเตอร์ของ branch 2 ใช้กำหนดจำนวน channel output ของ branch 2
  • red_5x5 เป็นพารามิเตอร์ของ branch 3 ใช้กำหนดจำนวน channel output ของ 1x1 ที่อยู่ใน block แรกของ branch 3
  • out_5x5 เป็นพารามิเตอร์ของ branch 3 ใช้กำหนดจำนวน channel output ของ branch 3
  • out_1x1 เป็นพารามิเตอร์ของ branch 4 ใช้กำหนดจำนวน channel output ของ branch 4

จะเห็นว่าในโปรแกรม จะมีการสร้าง branch จำนวน 4 branch โดยแต่ละ branch มีการกำหนดโครงสร้างดังนี้

  • branch 1 ประกอบด้วย 1 ConvBlock ทำหน้าที่ 1 x 1 Convolution
  • branch 2 ประกอบด้วย 2 ConvBlock โดยบล็อกแรก ทำหน้าที่ 1 x 1 Convolution เพื่อลดขนาดข้อมูล ก่อนจะไปเข้า 3 x 3 Convolution
  • branch 3 ประกอบด้วย 2 ConvBlock โดยบล็อกแรก ทำหน้าที่ 1 x 1 Convolution เพื่อลดขนาดข้อมูล ก่อนจะไปเข้า 5 x 5 Convolution
  • branch 4 ประกอบด้วย 2 ConvBlock โดยบล็อกแรก ทำ max pooling แล้วจึงเข้าสู่ บล็อกที่ 2 ซึ่งเป็น 1 x 1 Convolution เพื่อลดขนาดข้อมูล
  • จากนั้นจึงนำข้อมูลทั้ง 4 branch มาต่อเข้าด้วยกัน

โปรแกรมของคลาสมีดังนี้

class InceptionBlock(nn.Module):
'''
building block of inception-v1 architecture. creates following 4 branches and concatenate them
(a) branch1: 1x1 conv
(b) branch2: 1x1 conv followed by 3x3 conv
(c) branch3: 1x1 conv followed by 5x5 conv
(d) branch4: Maxpool2d followed by 1x1 conv

Args:
in_channels (int) : # of input channels
out_1x1 (int) : number of output channels for branch 1
red_3x3 (int) : reduced 3x3 referring to output channels of 1x1 conv just before 3x3 in branch2
out_3x3 (int) : number of output channels for branch 2
red_5x5 (int) : reduced 5x5 referring to output channels of 1x1 conv just before 5x5 in branch3
out_5x5 (int) : number of output channels for branch 3
out_1x1_pooling (int) : number of output channels for branch 4

Attributes:
concatenated feature maps from all 4 branches constituiting output of Inception module.
'''

def __init__(self , in_channels , out_1x1 , red_3x3 , out_3x3 , red_5x5 , out_5x5 , out_1x1_pooling):
super(InceptionBlock,self).__init__()

# branch1 : k=1,s=1,p=0
self.branch1 = ConvBlock(in_channels,out_1x1,1,1,0)

# branch2 : k=1,s=1,p=0 -> k=3,s=1,p=1
self.branch2 = nn.Sequential(
ConvBlock(in_channels,red_3x3,kernel_size=1,stride=1,padding=0),
ConvBlock(red_3x3,out_3x3,kernel_size=3,stride=1,padding=1))

# branch3 : k=1,s=1,p=0 -> k=5,s=1,p=2
self.branch3 = nn.Sequential(
ConvBlock(in_channels,red_5x5,kernel_size=1,stride=1,padding=0),
ConvBlock(red_5x5,out_5x5,kernel_size=5,stride=1,padding=2))

# branch4 : pool(k=3,s=1,p=1) -> k=1,s=1,p=0
self.branch4 = nn.Sequential(
nn.MaxPool2d(kernel_size=3,stride=1,padding=1),
ConvBlock(in_channels,out_1x1_pooling,kernel_size=1,stride=1,padding=0))

def forward(self,x):
# concatenation from dim=1 as dim=0 represents batchsize
return torch.cat([self.branch1(x),self.branch2(x),self.branch3(x),self.branch4(x)],dim=1)

เมื่อได้ Inception Block มาแล้ว ต่อไปก็จะนำมาสร้าง Model ดังนั้นจะเห็นได้ชัดเจนว่าโมเดล Inception มีความซับซ้อนของโมเดลมากกว่าโมเดลที่ผ่านมาค่อนข้างมากเลยทีเดียว

ในคลาสนี้ต้องการ input สองตัว คือ จำนวน channel ของ input และจำนวน class ของ output สุดท้าย

class Inceptionv1(nn.Module):
'''
step-by-step building the inceptionv1 architecture.

Args:
in_channels (int) : input channels. 3 for RGB image
num_classes : number of classes of training dataset

Attributes:
inceptionv1 model

For conv2 2 layers with first having 1x1 conv
'''

def __init__(self , in_channels , num_classes ):
super(Inceptionv1,self).__init__()

self.conv1 = ConvBlock(in_channels,64,7,2,3)
self.maxpool1 = nn.MaxPool2d(kernel_size=3,stride=2,padding=1)

self.conv2 = nn.Sequential(ConvBlock(64,64,1,1,0),ConvBlock(64,192,3,1,1))
self.maxpool2 = nn.MaxPool2d(kernel_size=3,stride=2,padding=1)

# in_channels , out_1x1 , red_3x3 , out_3x3 , red_5x5 , out_5x5 , out_1x1_pooling
self.inception3a = InceptionBlock(192,64,96,128,16,32,32)
self.inception3b = InceptionBlock(256,128,128,192,32,96,64)
self.maxpool3 = nn.MaxPool2d(kernel_size=3,stride=2,padding=1)

self.inception4a = InceptionBlock(480,192,96,208,16,48,64)
self.inception4b = InceptionBlock(512,160,112,224,24,64,64)
self.inception4c = InceptionBlock(512,128,128,256,24,64,64)
self.inception4d = InceptionBlock(512,112,144,288,32,64,64)
self.inception4e = InceptionBlock(528,256,160,320,32,128,128)
self.maxpool4 = nn.MaxPool2d(kernel_size=3,stride=2,padding=1)

self.inception5a = InceptionBlock(832,256,160,320,32,128,128)
self.inception5b = InceptionBlock(832,384,192,384,48,128,128)

self.avgpool = nn.AvgPool2d(kernel_size = 7 , stride = 1)
self.dropout = nn.Dropout(p=0.4)
self.fc1 = nn.Linear( 1024 , num_classes)


def forward(self,x):
x = self.conv1(x)
x = self.maxpool1(x)

x = self.conv2(x)
x = self.maxpool2(x)

x = self.inception3a(x)
x = self.inception3b(x)
x = self.maxpool3(x)

x = self.inception4a(x)
x = self.inception4b(x)
x = self.inception4c(x)
x = self.inception4d(x)
x = self.inception4e(x)
x = self.maxpool4(x)

x = self.inception5a(x)
x = self.inception5b(x)

x = self.avgpool(x)

x = self.dropout(x)
x = torch.flatten(x, start_dim=1)
x = self.fc1(x)

return x

รายละเอียดในคลาสมีดังนี้

  • ทำ convolution 2D โดยมี kernel จำนวน 64 แผ่น ดัวย kernel ขนาด 7x7 โดยมีstride=2 และ padding=3 จะได้ output ขนาด 112 x 112 จากนั้นทำ max pooling จะได้ output ขนาด 56 x 56
  • ทำ convolution 2D จำนวน 2 ครั้ง ครั้งที่ 1 kernel จำนวน 64 แผ่น ดัวย kernel ขนาด 1x1 โดยมีstride=1 และ padding=0 จะได้ จากนั้นต่อด้วยครั้งที่ 2 จำนวน 192 แผ่น ขนาด 3x3 โดยมีstride=1 และ padding=1 จะได้ output ขนาด 56 x 56 จำนวน 192 แผ่น จากนั้นทำ max pooling จะได้ output ขนาด 28 x 28
  • เข้า Inception Block จำนวน 2 block โดย Inception block ครั้งที่ 1 (inception3a) จะรับ input 192 channels และสร้าง output 256 channel จากการรวมกันของ 4 branch คือ 1) ทำ convolution 1x1 จำนวน channel=64 ได้ output 28 x 28 x 64 และ 2) ทำ convolution 1x1 จำนวน channel=96 ตามด้วย 3x3 จำนวน channel=128 ได้ output 28 x 28 x 128 และ 3) convolution 1x1 จำนวน channel=16 ตามด้วย 5x5 จำนวน channel=32 ได้ output 28 x 28 x 32 4) Max pooling ขนาด 3x3 ตามด้วย convolution 1x1 จำนวน channel=32 ได้ output 28 x 28 x 32 รวมเป็น 28 x 28 x 256 รูปด้านล่างแสดง เพื่อให้เห็นชัดเจนมากขึ้น
  • เข้า Inception Block 3b ได้ output 480 x 28 x 28
  • เข้า max pooling เหลือ 480 x 14 x 14
  • เข้า Inception Block จำนวน 5 block ได้ output 832 x 14 x 14
  • เข้า max pooling เหลือ 1024 x 7 x 7
  • เข้า average pooling เหลือ 1024 x 1 x 1
  • เข้าชั้น Fully Connected

โดยการทำงานทั้งหมดแสดงในภาพนี้ ไม่ขอลงรายละเอียดในชั้นอื่นๆ เพราะมีลักษณะคล้ายกัน และ มีแต่ตัวเลขน่าจะให้น่าเบื่อ ดูจากรูปจะเห็นชัดเจนกว่า

ตารางแสดงรายละเอียดของแต่ละ layer

หากแสดงผลลัพธ์ของการทำงานแต่ละขั้นจะได้ผลลัพธ์ที่มีรายละเอียดดังนี้

conv1 torch.Size([16, 64, 112, 112])
maxpool1 torch.Size([16, 64, 56, 56])
conv2 torch.Size([16, 192, 56, 56])
maxpool2 torch.Size([16, 192, 28, 28])
3a torch.Size([16, 256, 28, 28])
3b torch.Size([16, 480, 28, 28])
3bmax torch.Size([16, 480, 14, 14])
4a torch.Size([16, 512, 14, 14])
4b torch.Size([16, 512, 14, 14])
4c torch.Size([16, 512, 14, 14])
4d torch.Size([16, 528, 14, 14])
4e torch.Size([16, 832, 14, 14])
maxpool torch.Size([16, 832, 7, 7])
5a torch.Size([16, 832, 7, 7])
5b torch.Size([16, 1024, 7, 7])
AvgPool torch.Size([16, 1024, 1, 1])

โดยมีจำนวน พารามิเตอร์ทั้งหมด 7,005,832 พารามิเตอร์

from torchsummary import summary
summary(model, (3, 224, 224))

# output
"""
----------------------------------------------------------------
Layer (type) Output Shape Param #
================================================================
Conv2d-1 [-1, 64, 112, 112] 9,408
BatchNorm2d-2 [-1, 64, 112, 112] 128
. . .
. . .
. . .
Linear-253 [-1, 1000] 1,025,000
================================================================
Total params: 7,005,832
Trainable params: 7,005,832
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.57
Forward/backward pass size (MB): 118.72
Params size (MB): 26.73
Estimated Total Size (MB): 146.02
----------------------------------------------------------------

"""

ทดสอบการทำงาน

เอาละ! เมื่อได้ model ครบถ้วนแล้ว จะเขียนโปรแกรมทดสอบกัน

Dataset และ DataLoader

บทความนี้จะยังคงใช้ชุดข้อมูล CIFAR-10 ซึ่งประกอบด้วยข้อมูลภาพสี 60,000 ภาพ (RGB) ที่มีขนาด 32x32 จุด โดยแบ่งออกเป็น 10 ประเภท (class) ประเภทละ 6,000 ภาพ โดยจะแบ่งข้อมูลเป็น 2 ส่วน คือ ข้อมูลสำหรับสอนโมเดล จำนวน 50,000 ภาพ และ ข้อมูลสำหรับทดสอบ จำนวน 10,000 ภาพ

กำหนด hyperparameter ได้แก่

  • batch_size เป็นขนาด 16 หมายความว่าจะนำภาพเข้าไปในโมเดลครั้งละ 16 ภาพโดยภาพมีขนาด 32x32x3
  • num_class = 10 หมายความว่าภาพที่จะให้ทำนายมี 10 ประเภท หรือ 10 class
  • learning_rate = 0.0005 กำหนดอัตราการเรียนรู้เท่ากับ 0.0005
  • num_epochs = 21 กำหนดจำนวนรอบของการสอนโมเดล 21 รอบ

โหลดข้อมูลเพื่อนำมา train โดยจะใช้โมดูล dataset ที่อยู่ใน torchvision โดยโมดูลนี้จะช่วยในการโหลด dataset ให้มาอยู่ในเครื่องคอมพิวเตอร์ (./data) โดยในระหว่างโหลดสามารถแปลงรูปภาพ (transformation) ได้ด้วย จากโปรแกรม คือ แปลงให้เป็น Tensor และ Normalize ระดับสีของแต่ละสีให้เหมาะสม

เราจะสร้าง dataset เป็น 3 ชุด โดยชุดหนึ่งสำหรับฝึกสอนโมเดล (training set) อีกชุดหนึ่งสำหรับทดสอบความถูกต้อง (validation set) และชุดสุดท้ายสำหรับทดสอบความถูกต้อง โดยแบ่งในอัตรา 80:10:10 โดยในการสร้าง training set เขียนให้สามารถเลือกได้ว่าจะใช้ data augmentation หรือไม่ โดยหากใช้ก็จะมีการทำ RandomCrop และ RandomHorizontalFlip จากนั้นส่ง dataset ต่อไปยัง dataloader ซึ่งจะทำหน้าที่จัดข้อมูลเป็น batch เพื่อส่งเข้าไปในโมเดลครั้งละ 1 batch

นอกจากนั้นยังมีการขยายขนาดภาพจาก 32x32 เป็น 224x224 เนื่องจาก Inception รับข้อมูลขนาด 224 x 224

import numpy as np
import torch
import torch.nn as nn
from torchvision import datasets, transforms
from torch.utils.data import Dataset, DataLoader, SubsetRandomSampler
import matplotlib.pyplot as plt


# Define relevant variables for the ML task
batch_size = 16
num_classes = 10
learning_rate = 0.0005
num_epochs = 30

# Device configuration
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
torch.manual_seed(42)
torch.cuda.manual_seed_all(42)

class CIFAR10Dataset(Dataset):
def __init__(self, data_dir, train=True, augment=False, valid_size=0.1, random_seed=42):
self.data_dir = data_dir
self.train = train
self.augment = augment
self.valid_size = valid_size
self.random_seed = random_seed

self.normalize = transforms.Normalize(
mean=[0.4914, 0.4822, 0.4465],
std=[0.2023, 0.1994, 0.2010],
)

if self.train:
if self.augment:
self.transform = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.Resize((224, 224)),
transforms.ToTensor(),
self.normalize,
])
else:
self.transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
self.normalize,
])
else:
self.transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
self.normalize,
])

self.dataset = datasets.CIFAR10(
root=self.data_dir, train=self.train,
download=True, transform=self.transform,
)

if self.train:
num_train = len(self.dataset)
indices = list(range(num_train))
split = int(np.floor(self.valid_size * num_train))

np.random.seed(self.random_seed)
np.random.shuffle(indices)

self.train_idx, self.valid_idx = indices[split:], indices[:split]

def __len__(self):
return len(self.dataset)

def __getitem__(self, idx):
return self.dataset[idx]

def get_train_sampler(self):
return SubsetRandomSampler(self.train_idx)

def get_valid_sampler(self):
return SubsetRandomSampler(self.valid_idx)

def get_data_loaders(data_dir, batch_size, augment=False, valid_size=0.1, random_seed=42):
train_dataset = CIFAR10Dataset(data_dir, train=True, augment=augment, valid_size=valid_size, random_seed=random_seed)
test_dataset = CIFAR10Dataset(data_dir, train=False)

train_loader = DataLoader(
train_dataset, batch_size=batch_size, sampler=train_dataset.get_train_sampler()
)

valid_loader = DataLoader(
train_dataset, batch_size=batch_size, sampler=train_dataset.get_valid_sampler()
)

test_loader = DataLoader(
test_dataset, batch_size=batch_size, shuffle=True
)

return train_loader, valid_loader, test_loader

# Usage:
train_loader, valid_loader, test_loader = get_data_loaders(
data_dir='./data',
batch_size=batch_size,
augment=True,
random_seed=42
)

การสอน model (Training)

ยังคงใช้โครงสร้างเดียวกับโปรแกรมที่ผ่านมา โดยกำหนด epoch เป็น 20 epoch โดยฟังก์ชันที่ทำหน้าที่ train model มีดังนี้

def train_model(model, train_loader, valid_loader, criterion, optimizer, epochs=20, device='cuda'):
training_logs = {
"train_loss": [], "train_acc": [], "validate_loss": [], "validate_acc": []
}

for epoch in range(epochs):
# Training phase
model.train()
running_loss = 0.0
correct = 0
total = 0

for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)

optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()

running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()

train_loss = running_loss / len(train_loader)
train_accuracy = 100 * correct / total
training_logs["train_loss"].append(train_loss)
training_logs["train_acc"].append(train_accuracy)

# Validation phase
model.eval()
running_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for images, labels in valid_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()

valid_loss = running_loss / len(valid_loader)
valid_accuracy = 100 * correct / total
training_logs["validate_loss"].append(valid_loss)
training_logs["validate_acc"].append(valid_accuracy)

# if epoch % 5 == 0:
print(f'Epoch [{epoch+1}/{epochs}] :: ',end='')
print(f'Train Loss: {train_loss:.4f}, Train Accuracy: {train_accuracy:.2f}% ',end='')
print(f'Valid Loss: {valid_loss:.4f}, Valid Accuracy: {valid_accuracy:.2f}%')
print('-' * 80)

return training_logs


model = Inceptionv1(3,10).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate, weight_decay=0.005, momentum=0.9)

training_logs = train_model(model, train_loader, valid_loader, criterion, optimizer, epochs=num_epochs)

ได้ผลการทำงานดังนี้

Epoch [1/21] :: Train Loss: 1.5543, Train Accuracy: 42.63% Valid Loss: 1.2259, Valid Accuracy: 54.94%
--------------------------------------------------------------------------------
Epoch [2/21] :: Train Loss: 1.0902, Train Accuracy: 61.39% Valid Loss: 0.8356, Valid Accuracy: 69.56%
--------------------------------------------------------------------------------
Epoch [3/21] :: Train Loss: 0.8667, Train Accuracy: 69.83% Valid Loss: 0.7474, Valid Accuracy: 73.44%
--------------------------------------------------------------------------------
Epoch [4/21] :: Train Loss: 0.7298, Train Accuracy: 74.73% Valid Loss: 0.6112, Valid Accuracy: 79.02%
--------------------------------------------------------------------------------
Epoch [5/21] :: Train Loss: 0.6368, Train Accuracy: 78.20% Valid Loss: 0.5774, Valid Accuracy: 79.72%
--------------------------------------------------------------------------------
Epoch [6/21] :: Train Loss: 0.5778, Train Accuracy: 80.33% Valid Loss: 0.5554, Valid Accuracy: 81.34%
--------------------------------------------------------------------------------
Epoch [7/21] :: Train Loss: 0.5207, Train Accuracy: 82.26% Valid Loss: 0.4934, Valid Accuracy: 83.24%
--------------------------------------------------------------------------------
Epoch [8/21] :: Train Loss: 0.4713, Train Accuracy: 83.83% Valid Loss: 0.4463, Valid Accuracy: 85.20%
--------------------------------------------------------------------------------
Epoch [9/21] :: Train Loss: 0.4387, Train Accuracy: 85.06% Valid Loss: 0.3969, Valid Accuracy: 86.24%
--------------------------------------------------------------------------------
Epoch [10/21] :: Train Loss: 0.4032, Train Accuracy: 86.28% Valid Loss: 0.3917, Valid Accuracy: 86.54%
--------------------------------------------------------------------------------
Epoch [11/21] :: Train Loss: 0.3704, Train Accuracy: 87.32% Valid Loss: 0.3866, Valid Accuracy: 86.38%
--------------------------------------------------------------------------------
Epoch [12/21] :: Train Loss: 0.3379, Train Accuracy: 88.51% Valid Loss: 0.3788, Valid Accuracy: 87.10%
--------------------------------------------------------------------------------
Epoch [13/21] :: Train Loss: 0.3214, Train Accuracy: 88.93% Valid Loss: 0.3892, Valid Accuracy: 86.26%
--------------------------------------------------------------------------------
Epoch [14/21] :: Train Loss: 0.2972, Train Accuracy: 89.64% Valid Loss: 0.3489, Valid Accuracy: 87.96%
--------------------------------------------------------------------------------
Epoch [15/21] :: Train Loss: 0.2753, Train Accuracy: 90.49% Valid Loss: 0.3647, Valid Accuracy: 87.52%
--------------------------------------------------------------------------------
Epoch [16/21] :: Train Loss: 0.2606, Train Accuracy: 91.11% Valid Loss: 0.3330, Valid Accuracy: 88.64%
--------------------------------------------------------------------------------
Epoch [17/21] :: Train Loss: 0.2430, Train Accuracy: 91.50% Valid Loss: 0.3389, Valid Accuracy: 89.06%
--------------------------------------------------------------------------------
Epoch [18/21] :: Train Loss: 0.2259, Train Accuracy: 92.12% Valid Loss: 0.3505, Valid Accuracy: 88.80%
--------------------------------------------------------------------------------
Epoch [19/21] :: Train Loss: 0.2117, Train Accuracy: 92.55% Valid Loss: 0.3274, Valid Accuracy: 89.26%
--------------------------------------------------------------------------------
Epoch [20/21] :: Train Loss: 0.1960, Train Accuracy: 93.11% Valid Loss: 0.3414, Valid Accuracy: 88.98%
--------------------------------------------------------------------------------
Epoch [21/21] :: Train Loss: 0.1906, Train Accuracy: 93.46% Valid Loss: 0.3085, Valid Accuracy: 90.34%
--------------------------------------------------------------------------------

และแสดงกราฟดังนี้

def plot_graph(training_logs):
epochs = len(training_logs["train_loss"])
epochs_range = range(1, epochs + 1)

plt.figure(figsize=(12, 5))

# Plot loss
plt.subplot(1, 2, 1)
plt.plot(epochs_range, training_logs["train_loss"], label='Train Loss')
plt.plot(epochs_range, training_logs["validate_loss"], label='Valid Loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.title('Loss vs Epochs')

# Plot accuracy
plt.subplot(1, 2, 2)
plt.plot(epochs_range, training_logs["train_acc"], label='Train Accuracy')
plt.plot(epochs_range, training_logs["validate_acc"], label='Valid Accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy (%)')
plt.legend()
plt.title('Accuracy vs Epochs')

plt.tight_layout()
plt.show()

plot_graph(training_logs)

จากกราฟจะเห็นว่าแทบไม่ over-fitting เลย และเมื่อทดสอบด้วย Test set ด้วยโปรแกรม

def test_model(model, test_loader, criterion, device='cuda'):
model.eval()
running_loss = 0.0
correct = 0
total = 0

with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()

test_loss = running_loss / len(test_loader)
test_accuracy = 100 * correct / total

print(f'Test Loss: {test_loss:.4f}, Test Accuracy: {test_accuracy:.2f}%')
return test_loss, test_accuracy

test_loss, test_accuracy = test_model(model, test_loader, criterion)

ได้ผลเป็น Test Loss: 0.3500, Test Accuracy: 89.46% นับว่าไม่เลวเลย

สรุป

สรุปว่าได้ความถูกต้องประมาณ 90% เมื่อเทียบกับ VGG-16 ที่ได้ 85% แล้ว ถือว่ามีความก้าวหน้า แต่ที่ดีกว่ามาก คือ เวลาในการทำงาน โดยเมื่อเทียบกับ VGG-16 ซึ่งผมรันใช้เวลาประมาณ 10 ชั่วโมง (Core-i5 12400, RAM 32 G, GPU 3060) แต่ Inception รันใช้เวลาประมาณ 1 ชั่วโมง 20 นาทีเท่านั้น ซึ่งเร็วกว่ามากทีเดียว

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

ไว้เจอกันกับโมเดลต่อไปครับ

Reference :

  1. Implement Inception-v1 in PyTorch, Karunesh Upadhyay, https://medium.com/@karuneshu21/implement-inception-v1-in-pytorch-66bdbb3d0005

--

--

Thana Hongsuwan
Thana Hongsuwan

Written by Thana Hongsuwan

Maker สมัครเล่น สนใจเทคโนโลยีด้าน Hardware เช่น Arduino, ESP8266, ESP32, Internet of Things, Raspberry P, Deep Learning

No responses yet