0802-程式設計實戰_貓和狗二分類_深度學習專案架構

二十三歲的有德發表於2021-05-06

0802-程式設計實戰_貓和狗二分類_深度學習專案架構

pytorch完整教程目錄:https://www.cnblogs.com/nickchen121/p/14662511.html

一、比賽介紹

接下來我們將通過 pytorch 完成 Kaggle 上的經典比賽:Dogs vs. Cats

Dogs vs. Cats 是一個傳統的二分類問題,它的訓練集包含 25000 張圖片,這些圖片都放在同一個資料夾中,命名格式為 <category>.<num>.jpg,例如 cat.10000.jpgdog.100.jpg,測試集包含 12500 張圖片,命名為 <num>.jpg,例如 1000.jpg

參賽者需要根據訓練集的圖片訓練模型,並在測試集上進行預測,輸出它是狗的概率。最後提交的 csv 檔案如下,第一列是圖片的 <num>,第二列是圖片為狗的概率。

id label
10001 0.889
10002 0.01
... ...

二、資料載入

資料的相關處理主要儲存在 data/dataset.py 中。

關於資料載入,之前提過,基本原理就是先使用 Dataset 封裝資料集,再使用 Dataloader 實現資料並行載入。

Kaggle 提供的資料包括訓練集和測試集,但是在我們使用的時候,還需要從訓練集中抽取一部分作為驗證集。

對於上述所說的三個資料集,雖然它們的相應操作不太一樣,但是如果專門寫出三個 Dataset,則會顯得複雜並冗餘,因此在這裡通過新增一些判斷來區分三者。比如我們希望對訓練集做一些資料增強處理,如隨機裁剪、隨機翻轉、加噪聲等,但是對於驗證集和測試集則不需要。

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# Coding by https://www.cnblogs.com/nickchen121/
# Datatime:2021/5/3 10:15
# Filename:dataset.py
# Toolby: PyCharm

import os
from PIL import Image
from torch.utils import data
import numpy as np
from torchvision import transforms as T


class DogCat(data.Dataset):
    def __init__(self, root, transforms=None, train=True, test=False):
        """
        目標:獲取所有圖片地址,並根據訓練、驗證、測試劃分資料
        """
        self.test = test  # 獲取測試集
        imgs = [os.path.join(root, img)
                for img in os.listdir(root)]  # 拼接所有圖片路徑,路徑地址如下所示
        """
        test1: data/test1/8973.jpg
        train: data/train/cat.10004.jpg
        """

        # 區分資料集是否為測試集,並對資料集的圖片進行排序
        if self.test:
            imgs = sorted(
                imgs,
                key=lambda x: int(x.split('.')[-2].split('/')[-1]))  # 切割出 8973
        else:
            imgs = sorted(imgs,
                          key=lambda x: int(x.split('.')[-2]))  # 切割出 10004

        # 劃分訓練、驗證集,驗證:訓練 = 3:7
        imgs_num = len(imgs)
        if self.test:
            self.imgs = imgs
        elif train:
            self.imgs = imgs[:int(0.7 * imgs_num)]  # 訓練集來自資料集的前 70%
        else:
            self.imgs = imgs[int(0.7 * imgs_num):]

        # 資料轉換操作,測試驗證和訓練的資料轉換有所區別
        if transforms is None:

            # Normalize給定均值:(R,G,B) 方差:(R,G,B),將會把Tensor正則化
            normalize = T.Normalize(mean=[0.485, 0.456, 0.406],
                                    std=[0.229, 0.224, 0.225])

            # 測試集和驗證集
            if self.test or not train:
                self.transforms = T.Compose([
                    T.Scale(224),  # 讓圖片統一大小為:224*224
                    T.CenterCrop(224),  # 中心切割
                    T.ToTensor(),
                    normalize
                ])
            # 訓練集
            else:
                self.transforms = T.Compose([
                    T.Scale(256),  # 讓圖片統一大小為:256*256
                    T.RandomSizedCrop(224),  # 隨機切割圖片後,resize成給定的大小 224*224
                    T.RandomHorizontalFlip(),  # 一半的概率翻轉,一半的概率不翻轉
                    T.ToTensor(),
                    normalize
                ])

    def __getitem__(self, index):
        """
        返回一張圖片的資料
        如果是測試集,沒有圖片 id,如 8973.jpg 返回 8973

        test1: data/test1/8973.jpg
        train: data/train/cat.10004.jpg
        """
        img_path = self.imgs[index]
        if self.test:
            label = self.imgs[index].split('.')[-2]  # type:str # 切割出 8973.jpg
            label = int(label.split('/')[-1])  # 切割出 8973

        else:
            label = 1 if 'dog' in img_path.split(
                '/')[-1] else 0  # 切割出 cat.10004.jpg,通過判斷對圖片增加標籤

        data = Image.open(img_path)
        data = self.transforms(data)  # 對圖片進行處理

        return data, label

    def __len__(self):
        """
        返回資料集中所有圖片的個數
        """
        return len(self.imgs)

# train_dataset = DogCat(opt.train_data_root, train=True)  # opt 是未來會講到的配置物件
# trainloader = DataLoader(train_dataset,
#                          batch_size=opt.batch_size,
#                          shuffle=True,
#                          num_workers=opt.num_workers)
# 
# for ii, (data, label) in enumerate(trainloader):
#     train()

上述程式碼中我們需要注意三個點:

  • 把檔案讀取等費時操作放在 __getitem__ 函式中,利用多程式加速
  • 一次性把所有圖片讀進記憶體,不僅費時也會佔用較大記憶體,而且不方便進行資料增強操作
  • 訓練集中的 30% 作為驗證集,可以用來檢查模型的訓練效果,避免過擬合

三、模型定義

模型的定義主要儲存在 models 目錄下,其中 BasicModule 是對 nn.Module 的簡易封裝,提供快速載入和儲存模型的介面。

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# Coding by https://www.cnblogs.com/nickchen121/
# Datatime:2021/5/3 10:22
# Filename:BasicModule.py
# Toolby: PyCharm
import time
import torch as t


class BasicModule(t.nn.Module):
    """
    封裝了 nn.Module,主要提供 save 和 load 兩個方法
    """

    def __init__(self):
        super(BasicModule, self).__init__()
        self.model_name = str(type(self))  # 模型的預設名字

    def load(self, path):
        """
        可載入指定路徑的模型
        :param path:
        :return:
        """
        self.load_state_dict(t.load(path))

    def save(self, name=None):
        """
        儲存模型,預設使用“模型名字+時間”作為檔名,
        如 AlexNet_0710_23:57:29.pth
        :param name:
        :return:
        """
        if name is None:
            prefix = 'checkpoints/' + self.model_name + '.'
            name = time.strftime(prefix + '%m%d_%H:%M:%S.pth')
        t.save(self.state_dict(), name)
        return name

在實際使用中,直接呼叫 model.save() 以及 model.load(opt.load_path) 即可。

其他自定義模型一般繼承 BasicModule,然後實現自己的模型。由於實現了 AlexNet 和 ResNet34,在 models/__init__.py 中,可以寫下下述程式碼:

from .AlexNet import AlexNet
from .ResNet34 import ResNet34

這樣主函式中就可以寫:

from models import AlexNet
# 或
import models
model = models.AlexNet()
# 或
import models
model = getattr('models', 'AlexNet')()

上述在主函式中的程式碼中,其中最後寫法最關鍵,這樣意味著我們可以通過字串直接指定使用的模型,而不需要使用判斷語句,同時也不需要在每次新增加模型後都修改程式碼。

但是最好的方法,就是在新增模型後需要在 models.__init__.py 中加上 from .new_module import new_module,避免使用第一種方法時報錯,或者避免使用 model = getattr('models', 'AlexNet')() 時找不到該物件。

最後,在模型定義的時候,需要注意以下三點:

  • 儘量使用 nn.Sequenetial
  • 將經常使用的結構封裝為子 module
  • 將重複且有規律性的結構用函式生成

四、工具函式

在專案中,我們可能需要用到一些經常使用的方法,這些方法可以統一放入到 utils 資料夾中,需要時再匯入。

在這個專案中,主要封裝了視覺化工具 visdom 的一些操作。

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# Coding by https://www.cnblogs.com/nickchen121/
# Datatime:2021/5/3 10:23
# Filename:visualize.py
# Toolby: PyCharm
import visdom
import time
import numpy as np


class Visualizer(object):
    """
    封裝了 visdom 的基本操作,但仍然可以通過 `self.vis.function`
    或者 `self.function` 呼叫原生的 visdom 介面
    例如:
    self.text('hello visdom')
    self.histogram(t.randn(1000))
    self.line(t.arange(0, 10), t.arange(1, 11))
    """

    def __init__(self, env='default', **kwargs):
        self.vis = visdom.Visdom(env=env, **kwargs)

        # 儲存('loss', 23) 即 loss 的第 23 個點
        self.index = {}
        self.log_text = ''

    def reinit(self, env='default', **kwargs):
        """
        修改 visdom 的配置
        :param env:
        :param kwargs:
        :return:
        """
        self.vis = visdom.Visdom(env=env, **kwargs)
        return self

    def plot_many(self, d: dict):
        """
        一次 plot 多個
        :param d: dict(name, value) i.e. ('loss', 0.11)
        :return:
        """
        for k, v in d.items():
            self.plot(k, v)

    def img_many(self, d: dict):
        """
        處理多張圖片
        :param d:
        :return:
        """
        for k, v in d.items():
            self.img(k, v)

    def plot(self, name, y, **kwargs):
        """
        self.plot('loss', 1.00)
        :param name: 
        :param y: 
        :param kwargs: 
        :return: 
        """
        x = self.index.get(name, 0)
        self.vis.line(Y=np.array([y]),
                      X=np.array([x]),
                      win=name,
                      opts=dict(title=name),
                      update=None if x == 0 else 'append',
                      **kwargs)
        self.index[name] = x + 1

    def img(self, name, img_, **kwargs):
        """
        self.img('input_img', t.Tensor(64, 64))
        self.img('input_imgs', t.Tensor(3, 64, 64))
        self.img('input_img', t.Tensor(100, 1, 64, 64))
        self.img('input_imgs', t.Tensor(100, 3, 64, 64), nrows=10)
        :param name:
        :param img_:
        :param kwargs:
        :return:
        """
        self.vis.images(img_.cpu().numpy,
                        win=name,
                        opts=dict(title=name),
                        **kwargs)

    def log(self, info, win='log_text'):
        """
        self.log({'loss':1, 'lr':0.0001}
        :param info:
        :param win:
        :return:
        """
        self.log_text += ('[{time}] {info} <br>'.format(
            time=time.strftime('%m%d_%H%M%S'),
            info=info
        ))
        self.vis.text(self.log_text, win)

    def __getattr__(self, name):
        """
        自定義的 plot,image,log,plot_many 等除外
        self.function 等價於 self.vis.function
        :param name:
        :return:
        """
        return getattr(self.vis, name)

五、配置檔案

在模型定義、資料處理和訓練過程中會產生許多變數,這些變數應該提供預設值,並且統一放在配置檔案中。如此做的話,在後期除錯、修改程式碼的時候會方便很多,在這裡,我們把所有課配置項都放在 config.py 中。

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# Coding by https://www.cnblogs.com/nickchen121/
# Datatime:2021/5/3 10:20
# Filename:config.py
# Toolby: PyCharm
class DefaultConfig(object):
    env = 'default'
    model = 'AlexNet'  # 使用的模型,名字必須與 models/__init__.py 中的名字一致

    train_data_root = './data/train/'  # 訓練集存放路徑
    test_data_root = './data/test1'  # 測試集存放路徑
    load_model_path = 'checkpoints/model.pth'  # 載入預訓練模型的路徑,為 None 代表不載入

    batch_size = 128  # batch_size
    use_gpu = False  # use GPU or not
    num_workers = 4  # num of workers for loading data
    print_freq = 20  # print info every N batch

    debug_file = '/tmp/debug'  # if os.path.exists(debug_file): enter ipdb
    result_file = 'result.csv'

    max_epoch = 10
    lr = 0.1  # initial learning rate
    lr_decay = 0.95  # when val_loss increase, lr = lr*lr_decay
    weight_decay = 1e-4  # 損失函式

從上述程式碼中可以看出可配置的引數主要包括以下三類:

  • 資料集引數(檔案路徑、batch_size 等)
  • 訓練引數(學習率、訓練 epoch 等)
  • 模型引數

定義好了上述配置引數後,可以在程式中這樣使用配置引數:

import models
from config import DefaultConfig

opt = DefaultConfig()
lr = opt.lr
model = getattr(models, opt.model)
dataset = DogCat(opt.traini_data_error)

上述所說的都是預設引數,在預設配置類中,我們還可以提供一個更新函式,根據字典更新配置引數。

    def parse(self, kwargs: dict):
        """
        根據字典 kwargs 更新 config 引數
        :param kwargs:
        :return:
        """
        # 更新配置引數
        for k, v in kwargs.items():
            if not hasattr(self, k):
                warnings.warn(f"Warning: opt has not attribut {k}")
            setattr(self, k, v)

        # 列印配置資訊
        print('user config: ')
        for k, v in self.__class__.__dict__.items():  # type:str
            if not k.startswith('__'):
                print(k, getattr(self, k))

當然,在實際使用時沒必要每次修改 config.py,只需要通過命令列傳入所需要的引數,覆蓋預設配置就行,例如

opt = DefaultConfig()
new_config = {'lr': 0.1, 'use_gpu': False}
opt.parse(new_config)
opt.lr == 0.1

六、main.py

6.1 命令列工具 fire

在講解 main 檔案前,我們先熟悉一個我們可能可以用到的一個命令列工具 fire,可以通過 pip install fire 安裝,下面介紹下 fire 的基礎用法,假設 example.py 檔案程式碼如下:

# example.py
import file


def add(x, y):
    return x + y


def mul(**kwargs):
    a = kwargs['a']
    b = kwargs['b']
    return a * b


if __name__ == '__main__':
    fire.Fire()

那我們可以在命令列中通過以下語句呼叫 example 檔案中定義的函式:

python example.py add 1 2  # 執行 add(1, 2)
python example.py mul --a=1 --b=2  # 執行 mul(a=1, b=2), kwargs={'a':1, 'b':2}
python example.py add --x=1 --y=2  # 執行 add(x=1, y=2)

從上述程式碼可以看出,只要在程式中執行了 fire.Fire(),就可以通過命令列引數 `python file [args,] {--kwargs,}。當然,fire 還支援更多的高階功能,具體可以參考官方指南

6.2 main.py的程式碼組織結構

在我們這個專案的 main.py 中主要包括以下四個函式,其中三個需要命令列執行,main.py 的程式碼組織結構如下所示:

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# Coding by https://www.cnblogs.com/nickchen121/
# Datatime:2021/5/3 10:20
# Filename:main.py
# Toolby: PyCharm
import os
import csv
import ipdb
import fire
import torch as t
from torchnet import meter
from inspect import getsource
from torch.nn import functional
from torch.autograd import Variable
from torch.utils.data import DataLoader

import models
from config import opt
from data.dataset import DogCat
from utils.visualize import Visualizer


def train(**kwargs):
    """
    訓練
    :param kwargs:
    :return:
    """
    pass


def val(model, dataloader):
    """
    計算模型在驗證集上的準確率等資訊,用來輔助訓練
    :param model:
    :param dataloader:
    :return:
    """
    pass


def test(**kwargs):
    """
    測試(inference)
    :param kwargs:
    :return:
    """
    pass


def dc_help():
    """
    列印幫助的資訊
    :return:
    """
    print('help')


if __name__ == '__main__':
    fire.Fire()

main.py 搭建好這樣的組織結構後,可以通過 python main.py <function> --args==xx 的方式執行訓練或測試。

6.3 訓練

訓練的主要步驟如下:

  • 定義網路
  • 定義資料
  • 定義損失函式和優化器
  • 計算重要指標
  • 開始訓練
    • 訓練網路
    • 視覺化各種指標
    • 計算在驗證集上的指標

其中訓練函式的程式碼如下:

def train(**kwargs):
    """
    訓練
    :param kwargs:
    :return:
    """

    # 根據命令列引數更新配置
    opt.parse(kwargs)
    vis = Visualizer(opt.env)

    # step1:模型
    model = getattr(models, opt.model)()
    if opt.load_model_path:
        model.load(opt.load_model_path)
    if opt.use_gpu: model.cuda()

    # step2:資料
    train_data = DogCat(opt.train_data_root, train=True)
    val_data = DogCat(opt.train_data_root, train=False)
    train_dataloader = DataLoader(train_data,
                                  opt.batch_size,
                                  shuffle=True,
                                  num_workers=opt.num_workers)
    val_dataloader = DataLoader(val_data,
                                opt.batch_size,
                                shuffle=False,
                                num_workers=opt.num_workers)

    # step3:目標函式和優化器
    criterion = t.nn.CrossEntropyLoss()
    lr = opt.lr
    optimizer = t.optim.Adam(model.parameters(),
                             lr=lr,
                             weight_decay=opt.weight_decay)

    # step4:統計指標:平滑處理之後的損失,還有混淆矩陣
    loss_meter = meter.AverageValueMeter()  # 平均損失
    confusion_matrix = meter.ConfusionMeter(2)  # 混淆矩陣
    previous_loss = 1e100

    # 訓練
    for epoch in range(opt.max_epoch):

        loss_meter.reset()
        confusion_matrix.reset()

        for ii, (data, label) in enumerate(train_dataloader):

            # 訓練模型引數
            inp = Variable(data)
            target = Variable(label)
            if opt.use_gpu:
                inp = inp.cuda()
                target = target.cuda()
            optimizer.zero_grad()
            score = model(inp)
            loss = criterion(score, target)
            loss.backward()
            optimizer.step()

            # 更新統計指標及視覺化
            loss_meter.add(loss.data[0])
            confusion_matrix.add(score.data, target.data)

            if ii % opt.print_freq == opt.print_freq - 1:
                vis.plot('loss', loss_meter.value()[0])

                # 如果需要的話,進入 debug 模式
                if os.path.exists(opt.debug_file):
                    ipdb.set_trace()

        model.save()

        # 計算驗證集上的指標及視覺化
        val_cm, val_accuracy = val(model, val_dataloader)
        vis.plot('val_accuracy', val_accuracy)
        vis.log('epoch:{epoch},lr:{lr},loss:{loss},train_cm:{train_cm},val_cm{val_cm}'
                .format(epoch=epoch,
                        loss=loss_meter.value()[0],
                        val_cm=str(val_cm.value()),
                        train_cm=str(confusion_matrix.value()),
                        lr=lr))

        # 如果損失不再下降,則降低學習率
        if loss_meter.value()[0] > previous_loss:
            lr = lr * opt.lr_decay
            for param_group in optimizer.param_groups:
                param_group['lr'] = lr

        previous_loss = loss_meter.value()[0]

6.3.1 torchnet 中的 meter

在訓練的程式碼中,這裡用到了 PyTorchNet 裡的一個工具:meter。由於 PyTorchNet 是從 TorchNet 中遷移來的,提供了很多有用的工具,但目前的開發和文件都不是特別完善,這裡不多做贅述,只講上述用到的幾個方法。

mter 提供了一些輕量級工具,可以幫助使用者快速的統計訓練過程中的一些指標。
* AverageValueMeter 能夠計算所有數的平均值和標準差,可以用來統計一個 epoch 中損失的平均值
* confusionmeter 用來統計分類問題中的分類情況,是一個比準確率更詳細的統計指標,給出的是一個混淆矩陣

混淆矩陣舉例:

樣本 判為狗 判為貓
實際是貓 35 15
實際是狗 9 91

注:想詳細瞭解混淆矩陣的在第七小節

6.4 驗證

驗證相比較訓練來說簡單很多,但是需要注意把模型置於驗證模式(model.eval()),驗證完成後還需要把它設定回訓練模式(model.train()),這兩句程式碼會影響 BatchNorm 和 Dropout 等層的執行模式。驗證模型準確率的程式碼如下:

def val(model, dataloader):
    """
    計算模型在驗證集上的準確率等資訊,用來輔助訓練
    :param model:
    :param dataloader:
    :return:
    """
    # 把模型設定為驗證模式
    model.eval()

    confusion_matrix = meter.ConfusionMeter(2)
    for ii, data in enumerate(dataloader):
        inp, label = data
        val_inp = Variable(inp, volatile=True)
        val_label = Variable(label.long(), volatile=True)
        if opt.use_gpu:
            val_inp = val_inp.cuda()
            val_label = val_label.cuda()
        score = model(val_inp)
        confusion_matrix.add(score.data.squeeze(), label.long())

    # 把模型恢復為訓練模式
    model.train()

    cm_value = confusion_matrix.value()
    accuracy = 100. * (cm_value[0][0] + cm_value[1][1]) / (cm_value.sum())

    return confusion_matrix, accuracy

6.5 測試

測試的時候,需要計算每個樣本屬於狗的概率,並把結果儲存為 csv 檔案,測試的程式碼和驗證比較相似,但需要自己載入模型和資料。

def write_csv(results, file_name):
    with open(file_name, 'w') as f:
        writer = csv.writer(f)
        writer.writerow(['id', 'label'])
        writer.writerows(results)


def test(**kwargs):
    """
    測試(inference)
    :param kwargs:
    :return:
    """
    opt.parse(kwargs)

    # 模型
    model = getattr(models, opt.model)().eval()
    if opt.load_model_path:
        model.load(opt.load_model_path)
    if opt.use_gpu: model.cuda()

    # 資料
    train_data = DogCat(opt.test_data_root, test=True)
    test_dataloader = DataLoader(train_data,
                                 batch_sampler=opt.batch_size,
                                 shuffle=False,
                                 num_workers=opt.num_workers)

    results = []
    for ii, (data, path) in enumerate(test_dataloader):
        inp = Variable(data, volatile=True)
        if opt.use_gpu: inp = inp.cuda()
        score = model(inp)

        probability = probability = functional.softmax(score, dim=1)[:, 0].detach().tolist()
        batch_results = [(path_, probability_) for path_, probability_ in zip(path, probability)]
        results += batch_results

    write_csv(results, opt.result_file)
    return results

6.6 幫助函式

為了讓他人方便使用,程式中應該還需要提供一個幫助函式,用於說明函式是如何使用的。

程式的命令列介面有很多引數,如果手動用字串表示不僅複雜,而且後期修改 config 檔案時還需要修改對應的幫助資訊。為此,這裡使用 Python 標準庫中的 inspect 方法,可以自動獲取 config 的原始碼。

dg_help 的程式碼如下:

def dc_help():
    """
    列印幫助的資訊
    :return:
    """
    print('''
    usage:python{0} <function> [--args=value,]
    <function> := train | test | help
    example:
        python {0} train --env='env0701' --lr=0.01
        python {0} test --dataset='path/to/dataset/root/'
        python {0} help
    avaiable args:
    '''.format(__file__))

    source = (getsource(opt.__class__))  # 獲取配置資訊
    print(source)

七、使用

如 dc_help 函式列印的資訊描述的一樣,可以通過命令列引數指定變數名。下面是三個使用例子,fire 會把包含 “-” 命令列引數自動轉成下劃線 “_”,也會把非數字的數值轉成字串,所以 --train--data-root=data/train--train_data_root = 'data/train' 是等價的。

感興趣的可以把資料集下載下來進行測試:貓狗分類資料集

由於本章只是講解專案架構,我就不做測試,但是程式碼應該沒什麼大問題,修修補補就行了。

想要具體程式碼的可以加我微信:chenyoudea,但是沒必要找我要,我也沒有嘗試去跑通這個程式碼,並且我也沒有下載資料集,因為這一章沒必要。

# 訓練模型
python main.py train
    --train-data-root=data/train/
    --load-model-path=None
    --lr=0.005
    --batch-size=32
    --model='ResNet34'
    --max-epoch=20
    
python main.py train --train-data-root=data/train/ --load-model-path=None --lr=0.005 --batch-size=32 --model='ResNet34' --max-epoch=20

    
# 測試模型
python main.py test
    --test-data-root=data/test1
    --load-model-path=None
    --batch-szie=128
    --model='ResNet34'
    --num-workers=12
    
# 列印幫助資訊
python main.py dc_help

八、爭議

這裡還是多說一嘴,因為這個風格更多的是書籍作者陳雲老師的風格,並不是說以後你寫的程式碼都要以這個為標準,這個專案架構更多的是作為一個題意或一種參考。

也就是說,不要把本篇文章的觀點作為一個必須遵守的規範,但是前期的學習可以按照這個架構來,這樣不容易犯錯。但是,對於未來你遇到的很多專案,尤其對於每個公司的專案,專案架構相信都是不一樣的,不唯經驗主義,不唯教條主義,這才是一個碼農想進階的必經之路。

相關文章