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

Thana Hongsuwan
10 min readSep 7, 2024

--

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

ในปีต่อมา Microsoft ก็ได้นำเสนอโมเดลบ้าง ชื่อว่า Resnet คราวนี้ไม่เสียฟอร์ม โดยสามารถชนะในการแข่งขัน ImageNet ILSVRC ในปี 2015 ได้ ไม่เสียเชิงยักษ์ใหญ่โลกไอทีแล้ว (จริงๆ ในปี 2015 ทาง google ก็ได้ปรับปรุง Inception เป็น v3 และตั้งชื่อว่า ReCaption แต่ก็ได้ที่ 3)

Resnet มีชื่อเต็มๆ ว่า Microsoft ResNet (residual network) ที่เรียกว่า Resnet ก็เพราะมีส่วนประกอบที่เรียกว่า residual network นี่แหละ บทความนี้จะยังคงใช้ dataset CIFAR10 เหมือนเดิม เพื่อจะได้เปรียบเทียบความสามารถเมื่อเทียบกับ Inception ในบทความที่ผ่านมา

มีอะไรใหม่ใน ResNet

จากบทความที่ผ่านๆ มาที่เราได้ลองเอา PyTorch มาสร้าง Deep Artificial Neural Network ตั้งแต่ LeNet, AlexNet, VGGNet และล่าสุดคือ Inception สิ่งหนึ่งที่ทุกคนน่าจะสังเกตุได้ คือ ยิ่งโมเดลมีความลึกเท่าไร ก็ดูเหมือนว่ามันจะมีประสิทธิผลในการทำงานที่ดีขึ้น ซึ่งเรื่องนี้สามารถอธิบายได้ว่า เมื่อโมเดลมีความลึกมากขึ้น มันจะสามารถสกัดเอา feature ของภาพออกมาได้มากขึ้น ยิ่งโมเดลที่ลึกมากขึ้นก็มีความสามารถในการสกัด feature ที่ซับซ้อนมากขึ้น

หรือหากจะอธิบายในเชิงคณิตศาสตร์ ก็อาจอธิบายได้ว่า โมเดลที่มีความลึกมากขึ้น ก็คือ ฟังก์ชันที่มีความซับซ้อนมากขึ้นนั่นเอง เช่น สมการกำลังหนึ่ง ก็จะเป็นแค่เส้นตรง สมการกำลังสองก็จะเป็นพาราโบรา สมการกำลังสาม ก็เป็นเส้นที่ซับซ้อนขึ้น โดยสรุปคือ เมื่อโมเดลมีความลึกมากขึ้น ฟังก์ชันก็ซับซ้อนขึ้นตามไปด้วย ทำให้สามารถเรียนรู้ feature ที่ซับซ้อนมากๆ ได้ (earn features at many different levels of abstraction)

เดิมทีการสร้างโมเดลที่มีความลึกจะมีปัญหาในเรื่องของ computation cost คือเมื่อโมเดลมีขนาดใหญ่ก็จะใช้ขนาดของการประมวลผลมากขึ้น เช่น LeNet: ประมาณ 2 MFLOPs, AlexNet: ประมาณ 720 MFLOPs, VGG16: ประมาณ 15,300 MFLOPs (หรือ 15.3 GFLOPs) โดย MFLOPs ย่อมาจาก “Million Floating Point Operations Per Second” หรือ “ล้านการทำงานของจุดทศนิยมต่อวินาที” ซึ่งจะเห็นว่าเพิ่มจำนวนขึ้นเรื่อยครั้งละหลายสิบเท่า

แต่ในโมเดล Inception v1 (GoogLeNet) มีการปรับปรุงโมเดล โดยนำ 1 x 1 convolution มาใช้ ทำให้ลดขนาดโมเดลลงได้ โดย GoogLeNet ใช้ computation cost ประมาณ 1,500 MFLOPs ที่ความลึก 22 ชั้น โดยมีจำนวนพารามิเตอร์น้อยกว่า VGG ถึง 10 เท่า ทั้งที่มีความลึกมากกว่า ทำให้มีแนวคิดในการทำโมเดลที่ลึกขึ้น มีความเป็นไปได้มากขึ้น โดย ResNet ที่ชนะ ImageNet ILSVRC 2015 นั้น มีความลึกถึง 152 ชั้นเลยทีเดียว

เอาละ! ปัญหาเรื่อง computation cost จะเริ่มมีแนวทางแก้แล้ว แต่อีกปัญหาหนึ่งยังอยู่ นั่นก็คือปัญหา vanishing gradient ปัญหานี้ หากแปลเป็นภาษาไทย คือ การจางหายไปของ gradient โดยค่า gradient จะถูกคำนวณระหว่างที่มีการทำงาน และนำ gradient มาใช้ปรับพารามิเตอร์ โดยการคำนวณ gradient ส่วนหนึ่ง คือ การคูณ เช่น 0.1 x 0.1 จะได้เท่ากับ 0.01 แต่ถ้า 0.1 x 0.1 x 0.1 ก็จะเหลือเพียง 0.001 แล้วถ้าคูณกับแบบนี้ 22 ครั้ง ค่าที่ได้จะเหลือเท่าไร ก็เหมือนกับที่แสดงในภาพ ที่ครั้งที่ 0 มีสีเข้ม แต่เมื่อถึงครั้งที่ 100 ก็จะจางลงจนมีค่าน้อยๆ และอาจถูกคอมพิวเตอร์ปัดเป็น 0 ไปเลยก็ได้ แปลว่า หากต้องการทำ Model ที่มีจำนวนชั้นมาก ๆ จำเป็นต้องแก้ปัญหา vanishing gradient ให้ได้ ไม่เช่นนั้นจะไม่สามารถรักษาพารามิเตอร์เอาไว้ได้

ภาพที่ 1 vanishing gradient problem

skip connection

ในการแก้ปัญหา vanishing gradient นั้น คณะผู้วิจัยของ Microsoft (He et al.) เสนอวิธีการที่เรียกว่า skip connection โดยหลักการของ skip connection คือ จะสร้างเส้นทางที่ส่งผ่าน x ไปยังชั้นถัดไป โดยมีหลักการว่าหากฟังก์ชันของ x คือ F(x) มีค่าเป็น 0 อย่างน้อยก็ควรจะส่งค่า x ต่อไปข้างหน้า พูดง่ายๆ คือ ถ้าผลลัพธ์ของฟังก์ชันแย่กว่าเดิม ก็ขอรักษาของเดิมเอาไว้ดีกว่า จากรูปด้านล่างจะเห็นว่า อินพุต คือ x และหาก F(x) ได้ผลลัพธ์เป็น 0 การมี skip connection จะช่วยให้ยังรักษาค่า x เอาไว้ได้

ภาพที่ 2 แสดง skip connection

รูปด้านล่างแสดงแนวคิดของ skip connection หากสังเกตุจะพบว่าจุดที่ bypass ค่าx ไปยัง layer ถัดไปนั้น ไม่ใช่ด้านท้ายของของ output เสียทีเดียว

ภาพที่ 3 แสดง skip connection

เนื่องจากในการรวมข้อมูลจากทั้งสองเส้นทาง (x กับ F(x)) จะต้องกระทำก่อนที่จะผ่านชั้น ReLU activation ของชั้นนั้น

รูปด้านล่างจะแสดงให้เห็นได้ชัดเจนขึ้น ว่าข้อมูล X ส่งผ่านมาตามเส้นทางลัด และถูกบวกเข้ากับเส้นทางหลัก (F(x)) จากนั้นจึงนำผลการบวกเข้าสู่ ReLU activation ดังนั้น output คือ relu(F(x) + x)

ภาพที่ 4 แสดงตำแหน่งที่บวกกับ shortcut path

ใน block ที่มีการทำงาน Convolution มากกว่า 1 บล็อก โดยมีการส่งผ่าน skip connection ตามรูปจะเรียกว่า residual block โดยคำว่า “residual” หมายถึง “สิ่งที่เหลืออยู่” หรือ “ส่วนที่ยังคงอยู่” ในบริบทของ residual block การออกแบบนี้มีจุดมุ่งหมายเพื่อให้โมเดลเรียนรู้ “ความแตกต่าง” ระหว่าง input ดั้งเดิม x และ output ที่คาดหวังของ layer ซึ่งความแตกต่างนี้ก็คือฟังก์ชันที่เหลืออยู่หรือ “Residual Function” นั่นเอง

และเช่นเดียวกับ Inception network ใน ResNet ก็จะประกอบด้วยลำดับของ residual block โดยรูปด้านล่างนี้แสดงการเปรียบเทียบระหว่าง CNN แบบดั้งเดิม, Inception Module และ Residual Block จะเห็นว่าใน ResNet ก็จะมีการนำเอา residual block มาเรียงต่อๆ กัน

ภาพที่ 5 เปรียบเทียบ Inception module และ Residual block

จากรูปข้างต้นจะเห็นได้ว่า ใน ResNet มีโครงสร้างของสถาปัตยกรรมคล้ายกับ Inception ในช่วงต้น คือ จะมี convolution layer และ pooling layer ในชั้นแรกๆ เพื่อทำหน้าที่เป็น Feature extractors จากนั้นก็ตามด้วย residual block และปิดท้ายด้วย fully connected layer และตามด้วย Softmax ที่จะทำหน้าที่เป็น Classifiers

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

Residual blocks

เอาละ! คราวนี้มาดูโครงสร้างของ residual module ซึ่งประกอบด้วย 2 เส้นทาง

  • เส้นทางหลัก (main path) เป็นเส้นทางที่เป็นลำดับของชั้น convolution และ ReLu activation โดยมีการเพิ่ม batch normalization เพื่อปรับข้อมูล ทำให้ gradient มีเสถียรภาพมากขึ้น ซี่งจะทำให้การ train ทำได้เร็วขึ้น และลดการเกิด over-fitting โดยเส้นทางหลังจะคล้ายๆ กับแบบนี้ [CONV ⇒ BN ⇒ ReLU] × 3.
  • เส้นทางลัด (shortcut path) จะทำหน้าที่เชื่อมข้อมูล x ไปยังส่วนท้ายของบล็อกที่ 3 ตามรูป
ภาพที่ 6 แสดง residual block

จะเห็นได้ว่าเส้นทางลัดจะบวกเข้ากับเส้นทางหลักก่อนที่จะผ่าน activation function ของ convolutional layer สุดท้าย จากนั้นจึงค่อยผ่าน ReLU อีกที

การทำงานใน Residual block สามารถนำมาเขียนเป็นโปรแกรมได้ดังนี้

class ResidualBlock(nn.Module):
expansion = 1 # Define expansion for basic block as 1

def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super(ResidualBlock, self).__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
self.conv2 = nn.Sequential(
nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1),
nn.BatchNorm2d(out_channels)
)
self.downsample = downsample
self.relu = nn.ReLU(inplace=True)

def forward(self, x):
residual = x
out = self.conv1(x)
out = self.conv2(out)
if self.downsample:
residual = self.downsample(x)
out += residual
out = self.relu(out)
return out

จากโปรแกรมข้างต้นจะเห็นว่า class ResidualBlock จะมี conv เป็น 2 แบบ คือ conv1 เป็นบล็อกของ Conv2d + BatchNorm2d + ReLU และ conv1 จะมีเพียง Conv2d + BatchNorm2d จากนั้นใน def forward(self, x) จะเห็นว่า โปรแกรมจะนำ x มาผ่าน conv1(ซึ่งเป็น block เต็ม) และตามด้วย conv2 (ซึ่งไม่มี ReLU) และหากมี downsample ก็จะนำมาผ่าน downsample เสียก่อน จากนั้นจึงนำผลลัพธ์มาบวกกับ x เดิม (residual) แล้วจึงนำมาผ่าน ReLU เป็นครั้งสุดท้าย ซึ่งเหมือนกับการทำงานในภาพทุกอย่าง

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

เมื่อเราได้ residual block แล้วต่อไปก็เพียงแต่นำ residual block มาต่อกันไปเรื่อยๆ ตามต้องการ ดังภาพต่อไปนี้

ResNet

ภาพที่ 7 แสดงการเปรียบเทียบระหว่าง Inception, CNN ปกติ และ ResNet

จากภาพจะเห็นโครงสร้างของ ResNet-34 ซึ่งประกอบด้วย 34 Layer โดยแบ่งออกเป็น 4 กลุ่มตามรูป โดยกลุ่มแรกประกอบด้วย 5 residual block ที่มีจำนวน channel หรือแผ่น 64 แผ่น กลุ่มที่ 2 ประกอบด้วย 8 residual block ที่มีจำนวน channel หรือแผ่น 128 แผ่น กลุ่มที่ 3 ประกอบด้วย 12 residual block ที่มีจำนวน channel หรือแผ่น 256 แผ่น กลุ่มที่ 4 ประกอบด้วย 6 residual block ที่มีจำนวน channel หรือแผ่น 512 แผ่น

ดังนั้นสามารถเขียนเป็น class ResNet ได้ดังนี้

class ResNet(nn.Module):
def __init__(self, block, layers, num_classes=10):
super(ResNet, self).__init__()
self.inplanes = 64 # Initial number of input channels

# Initial convolutional layer
self.conv1 = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True)
)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

# Create each ResNet layer
self.layer0 = self._make_layer(block, 64, layers[0], stride=1)
self.layer1 = self._make_layer(block, 128, layers[1], stride=2)
self.layer2 = self._make_layer(block, 256, layers[2], stride=2)
self.layer3 = self._make_layer(block, 512, layers[3], stride=2)

# Average pooling and fully connected layer for classification
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)

def _make_layer(self, block, planes, blocks, stride=1):
"""Create a ResNet layer using the specified block type and number of blocks."""
downsample = None
# Downsample if needed to match the dimensions
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(
nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(planes * block.expansion),
)

layers = []
# Add the first block with the downsample layer
layers.append(block(self.inplanes, planes, stride, downsample))
self.inplanes = planes * block.expansion # Update inplanes after first block

# Add subsequent blocks
for _ in range(1, blocks):
layers.append(block(self.inplanes, planes))

return nn.Sequential(*layers)

def forward(self, x):
# Forward pass through the network
x = self.conv1(x)
x = self.maxpool(x)
x = self.layer0(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)

# Pooling and final linear layer
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)

return x

จาก class ResNet ให้เทียบกับรูปที่ 7 จะเห็นว่ามีลำดับการทำงานเหมือนในรูป ดังนี้

  • เริ่มจากทำ convolution ขนาดด้วย kernel ขนาด 7 x 7, stride=2, padding=3 โดยมีจำนวน channel=64 และตามด้วย batch normalization และ ReLU จากนั้นทำ max pooling 1 ครั้ง
  • ทำงานใน block ที่ 1 โดยในโปรแกรมจะใช้คำว่า layer0 โดยเกิดจากการสร้าง residual block (เป็นพารามิเตอรที่ส่งไปตามโปรแกรมด้านล่าง) โดยผ่าน function ชื่อว่า _make_layer โดยมีพารามิเตอร์ของจำนวน channel เป็น 64 และ stack กัน 3 ชั้น
  • ทำงานใน block ที่ 2–4 โดยในโปรแกรมจะใช้คำว่า layer1–3 เกิดจากการสร้าง residual block โดยผ่าน function ชื่อว่า _make_layer โดยมีพารามิเตอร์ของจำนวน channel เป็น 128, 256, 512 ตามลำดับ โดยมีจำนวนชั้น 4, 6, 3 ตามลำดับ ซึ่งจะเหมือนกับในรูปที่ 7
  • สำหรับฟังก์ชัน _make_layer จะในบรรทัดแรก คือ if stride != 1 or self.inplanes != planes: จะเป็นการตรวจสอบว่าเป็น layer0 หรือเปล่า ถ้าเป็น layer0 ก็จะกำหนดให้มี downsample แต่ layer อื่นไม่ต้องมีเนื่องจากช่วงรอยต่อชองแต่ละ layer อาจมี “residual connection” ไม่ตรงกับขนาดของเอาต์พุตที่ได้จากการคำนวณ convolution ดังนั้นจึงต้องปรับให้ตรงกันผ่าน downsample จากนั้นจะมีการ add residual block ตามจำนวนที่กำหนด
  • เมื่อทำส่วน convolution เรียบร้อยก็มาทำ average pooling อีกครั้ง และ flatten จากนั้นจึงเข้า fully connected layer เป็นอันจบ
model = ResNet(ResidualBlock, [3, 4, 6, 3]).to(device)

ใน ResNet จะมีหลาย configuration เช่นกัน โดยมีรายละเอียดตามตาราง จะเห็นได้ว่ามีตั้งแต่ 18 layer ไปจนถึง 152 layer

  • ชั้น conv1 จะทำ convolution ขนาด 7 x 7 จำนวน 64 channel โดยได้ output ขนาด 112 x 112
  • ชั้น conv2 จะมี block จำนวน 2–3 block โดยได้ output ขนาด 56 x 56
  • ชั้น conv3 จะมี block จำนวน 2–4 block โดยได้ output ขนาด 28 x 28
  • ชั้น conv4 จะมี block หลากหลาย ตั้งแต่ 2 ไปจนถึงสูงสุดคือ 36 block โดยได้ output ขนาด 14 x 14
  • ชั้น conv5 จะมี block จำนวน 2–3 block โดยได้ output ขนาด 7 x 7

และให้สังเกตุว่าในการทำงานทั้งหมด ไม่มี pooling layers อยู่ใน residual block เลย และอาจสังเกตุเห็นอีกว่าใน ResNet ที่มีจำนวนชั้นมากๆ ได้แก่ ResNet-50, ResNet-101 และ ResNet-152 จะนำ 1 × 1 convolutional layer มาใช้เป็น downsampling เพื่อลดขนาดแทน โดยเรียกส่วนนี้ว่า bottleneck residual block

ภาพด้านล่างนี้จะแสดง โครงสร้างของ ResNet ที่ฝั่งขวา และ แสดงโครงสร้างของ residual block กับ bottleneck residual block (เรียกสั้นๆ ว่า bottleneck block) จะเห็นว่าใน residual block จะมีการทำงานเพียงการสร้างเส้นทางเพื่อนำค่า x มาบวกเข้ากับผลลัพธ์ที่ผ่าน convolution layer เท่านั้น แต่ใน bottleneck block จะมีการทำ 1 x 1 convolution ก่อน แล้วค่อยทำ 3 x 3 convolution และ ปิดท้ายด้วย 1 x 1 convolution อีกครั้ง

ภาพที่ 8 แสดง residual block เทียบกับ bottleneck residual block

อย่างไรก็ตามในการนำ residual block มาซ้อนต่อกันไปเรื่อยๆ อาจทำให้มิติของข้อมูลมีการเปลี่ยนแปลงไป ทำให้ไม่สามารถจะบวกกันได้ ดังนั้น เพื่อให้สามารถบวกกันได้ (ทำให้เมทริกซ์มีมิติเท่ากัน) ก็มีความจำเป็นจะต้องมีการทำ downsample ข้อมูล x ที่อยู่ใน shortcut path ด้วย โดยการเพิ่ม 1 × 1 convolutional layer + batch normalization เข้าไปใน shortcut path ด้วยตามรูป โดยจะเรียกว่า reduce shortcut

ภาพที่ 9 แสดง shortcut แบบปกติ กับ reduce shortcut

เพื่อแสดงให้เห็นการเปรียบเทียบระหว่าง การบวกแบบมิติเท่ากัน กับ การบวกแบบมิติไม่เท่ากัน

จากรูป input ขนาด 28 x 28 จำนวน 512 channel เมื่อถึง output จะมีขนาด 28 x 28 จำนวน 512 channel เท่ากัน จึงสามารถบวกกันได้เลย

แต่รูปด้านบน input ขนาด 28 x 28 จำนวน 256 channel เมื่อถึง output จะมีขนาด 28 x 28 จำนวน 512 channel ดังนั้นจึงบวกกันไม่ได้ จะต้องมีการทำ downsample ก่อนแล้วค่อยบวกกัน

ดังนั้นส่วนของ bottleneck residual block จึงมีการทำงานที่ต่างออกไป โดยมีโปรแกรมดังนี้

class Bottleneck(nn.Module):
expansion = 4 # Define expansion factor for Bottleneck block

def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super(Bottleneck, self).__init__()
# Define the three convolutional layers using nn.Sequential
self.conv1 = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False),
nn.BatchNorm2d(out_channels)
)
self.conv2 = nn.Sequential(
nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False),
nn.BatchNorm2d(out_channels)
)
self.conv3 = nn.Sequential(
nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, stride=1, bias=False),
nn.BatchNorm2d(out_channels * self.expansion)
)
self.relu = nn.ReLU(inplace=True)
self.downsample = downsample

def forward(self, x):
identity = x

# Pass through the sequential blocks
out = self.conv1(x)
out = self.relu(out)
out = self.conv2(out)
out = self.relu(out)
out = self.conv3(out)

# Apply downsample if needed
if self.downsample is not None:
identity = self.downsample(x)

# Add the residual connection and apply final ReLU
out += identity
out = self.relu(out)

return out

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

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.001
num_epochs = 20

# 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
)

จากนั้นก็เป็นโปรแกรมส่วนของการ train จะไม่อธิบายเช่นเดียวกัน หากต้องการทราบรายละเอียดให้อ่านบทความก่อนหน้านี้

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 = ResNet(ResidualBlock, [3, 4, 6, 3]).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)

โปรแกรมนี้จะเป็น ResNet-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()


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

plot_graph(training_logs)

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

การทดสอบโปรแกรมจะแยกทดสอบ ResNet-34, ResNet-50, ResNet-101 และ ResNet-152 โดยจะใช้ CIFAR10 เป็น dataset โดยใช้เครื่อง Core-i5, RAM 32 G, GPU RTX-3060–12G จำนวน 30 epoch ได้ผลดังนี้

  • ResNet-34 ใช้เวลาทำงาน 68 นาที ความถูกต้อง 87.06%
  • ResNet-50 ใช้เวลาทำงาน 98 นาที ความถูกต้อง 87.32%
  • ResNet-101 ใช้เวลาทำงาน 238 นาที ความถูกต้อง 87.61%
  • ResNet-152 ใช้เวลาทำงาน 327 นาที ความถูกต้อง 87.82%

สรุป

จะเห็นว่าโมเดล ResNet สำหรับ Dataset นี้ มีเปอร์เซนต์ความถูกต้องพอๆ กับ Inception แต่จุดเด่นของโมเดล ResNet คือ สามารถสร้างโมเดล Deep Learning ได้ Deep มากโดยไม่ทำให้ประสิทธิภาพลดลง (ก่อนหน้านี้หากโมเดลที่ลึกมาก จะทำให้ประสิทธิภาพลดลง) โดยใช้แนวคิดเรื่องของ skip connection ซึ่งถือเป็นแนวคิดที่สำคัญของ deep learning

อ้างอิง

  1. Implement ResNet in PyTorch, https://medium.com/@karuneshu21/how-to-resnet-in-pytorch-9acb01f36cf5

2. Writing ResNet from Scratch in PyTorch, https://blog.paperspace.com/writing-resnet-from-scratch-in-pytorch/

--

--

Thana Hongsuwan
Thana Hongsuwan

Written by Thana Hongsuwan

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

No responses yet