M l ops

Tổ chức code trong dự án AI

Tổ chức code trong dự án AI

Nếu bạn là người theo nghiệp Code được vài năm, bạn chắc đã từng trải qua cảm giác bực bội, khó chịu khi phải đọc code của một dự án có từ trước đó. Nó được viết một cách cẩu thả, tùy tiện, không theo một quy định hay tổ chức nào cả. Người ta gọi đó là Source Code không có tính Maintainance. Là một Coder có trách nhiệm, có đạo đức, chúng ta nên viết code làm sao cho người đến sau, khi đọc code của bạn có thể nhanh chóng hiểu được vấn đề. “Người đến sau” ở đây có thể là chính bạn, vì sau một thời gian thì có khi chính bạn cũng quên hết những gì mình từng viết. Việc viết code một cách cẩn thận còn làm giảm rủi ro phát sinh các lỗi tiềm tàng trong quá trình sử dụng về sau này.

Bất kỳ dự án phần mềm nào cũng đều phải có quy định về cách tổ chức Source Code trong dự án, ngay từ khi bắt đầu dự án. Các dự án về AI cũng không ngoại lệ. Trong bài này, chúng ta sẽ cùng xem xét một cách tổ chức Source Code cho các dự án AI. Và nếu bạn thấy phù hợp thì có thể áp dụng cho các dự án của bạn.

1. Project Structure

Một dự án được coi là tổ chức tốt khi nó có tính module. Tức là mỗi chức năng của dự án được tách riêng ra thành các thành phần khác nhau. Đó gọi là nguyên lý Separation of Concerns. Theo cách này, chúng ta có thể dễ dàng chỉnh sửa, nâng cấp, bổ sung mỗi chức năng mà không ảnh hưởng quá nhiều đến các chức năng khác. Hơn thế nữa, nó còn làm tăng khả năng tái sử dụng code hiệu quả, hạn chế việc code bị trùng lặp không cần thiết. Từ đó, toàn bộ Source Code của dự án sẽ gọn gàng, sáng sủa, dễ nâng cấp, hạn chế lỗi, … hơn rất nhiều.

Đối với các dự án về AI, một cách tổ chức Source Code dự án có thể như sau:

Trước hết, chúng ta cần phân biệt modulepackage. Module chỉ đơn giản là các file chứa Python code và chúng có thể được Import lẫn nhau. Trong khi đó, Package là một thư mục chứa nhiều Modules hoặc các Sub-packages. Để có thể Import được Package, cần phải có một file init.py trong Package đó. File này có thể rỗng.

Như vậy, trong cách tổ chức này, chúng ta có 8 Packages khác nhau:

  • configs: Chứa các thông tin cấu hình của hệ thống, có thể thay đổi trong quá trình phát triển và sử dụng. Ví dụ: Các Hyper-parameters, các đường dẫn đến Dataset, Model, các thông số trong kiến trúc của Model, các thông số để huấn luyện Model, … Một cấu hình ví dụ đơn giản như sau:
CFG = {
   "data": {
       "path": "oxford_iiit_pet:3.*.*",
       "image_size": 128,
       "load_with_info": True
   },
   "train": {
       "batch_size": 64,
       "buffer_size": 1000,
       "epoches": 20,
       "val_subsplits": 5,
       "optimizer": {
           "type": "adam"
       },
       "metrics": ["accuracy"]
   },
   "model": {
       "input": [128, 128, 3],
       "up_stack": {
           "layer_1": 512,
           "layer_2": 256,
           "layer_3": 128,
           "layer_4": 64,
           "kernels": 3
       },
       "output": 3
   }
}
  • dataloader: Chứa code liên quan đến việc data loadingdata preprocessing.
  • evaluation: Chứa code thực hiện việc đánh giá hiệu năng và độ chính xác của model.
  • executor: Chứa code (function, script) để huấn luyện model, hoặc sử dụng model để dự đoán. Trong Package này thường có file main.py.
  • model: Chứa code định nghĩa kiến trúc của model.
  • notebooks: Chứa tất cả các Jupyter/Colab Notebook của dự án (nếu có).
  • ops: Chứa code liên quan đến các hoạt động kiểu như algebraic transformations, image manipulation techniques hay graph operations. Package này có thể có hoặc không.
  • utils: Chứa các common functions, có thể sử dụng ở nhiều nơi trong các Packages khác. Những gì mà không nằm trong số các Packages kể trên thì cũng có thể đưa vào Package này.

2. Object Oriented Programming (OOP)

Lập trình hướng đối tượng (OOP) thường không được sử dụng nhiều trong Python giống như Java hay C#. Có lẽ là bởi vì Python là một ngôn ngữ lập trình kiểu Script. Tức là cứ viết là chạy, không cần phải có hàm, phải khai báo, bla bla.

Tuy nhiên, Python cũng hỗ trợ lập trình theo kiểu OOP. Và trong một dự án lớn thì sử dụng OOP cho Python mang lại hiệu quả rất tích cực, giống như Java hay C#.

2.1 Tính đóng gói (encapsolution)

Ví dụ, ta viết một Class tên là Unet như sau:

class UNet():
    def __init__(self, config):
        self.base_model = tf.keras.applications.MobileNetV2( input_shape=self.config.model.input, include_top=False)
        self.batch_size = self.config.train.batch_size
        . . .
    def load_data(self):
        """Loads and Preprocess data """
        self.dataset, self.info = DataLoader().load_data(self.config.data)
        self._preprocess_data()
    def _preprocess_data(self):
        . . .
    def _set_training_parameters(self):
        . . .
    def _normalize(self, input_image, input_mask):
       . . .
    def _load_image_train(self, datapoint):
       . . .
    def _load_image_test(self, datapoint):
        . . .
    def build(self):
        """ Builds the Keras model based """
        layer_names = [
            'block_1_expand_relu',  # 64x64
            'block_3_expand_relu',  # 32x32
            'block_6_expand_relu',  # 16x16
            'block_13_expand_relu',  # 8x8
            'block_16_project',  # 4x4
        ]
        layers = [self.base_model.get_layer(name).output for name in layer_names]
        . . .
        self.model = tf.keras.Model(inputs=inputs, outputs=x)
    def train(self):
        . . .
    def evaluate(self):
        . . .

Bạn có thể nhìn thấy rằng mỗi chức năng được đóng gói bên trong một phương thức riêng biệt, và các thuộc tính được khai báo như là các instance variables. Cách viết như thế này rõ ràng là giúp code trở nên sáng sủa, dễ mở rộng và bảo trì. Đây chính là tính đóng gói (encapsolution) của OOP. Có một điều mình nhận thấy là OOP trong Python không thiết lập phạm vi truy cập các thuộc tính và phương thức trong một Class. Tất cả chúng đều là public, tức có thể truy cập từ mọi nơi. TUy nhiên, để bắt chước cho giống với OOP chuẩn, khi code Python, chúng ta thường quy ước như sau:

  • Để khai báo phạm vi truy cập thành phần trong Class là public thì tên của chúng phải bắt đầu bằng 1 chữ cái.
  • Để khai báo phạm vi truy cập thành phần trong Class là protected thì tên của chúng phải bắt đầu bằng ký tự “_”.
  • Để khai báo phạm vi truy cập thành phần trong Class là public thì tên của chúng phải bắt đầu bằng 2 ký tự “_” (tức là __).

2.2 Tính thừa kế, trừa tượng và đa hình

Ngoài tính Encapsolution, OOP có 3 tính chất quan trọng nưã là tính kế thừa(inheritance), tính trừu tượng(abstraction) và tính đa hình(polymorphism).

Giả sử, khi bắt đầu viết code cho dự án, chúng ta mới chỉ hình dung sơ bộ là chắc chắn sẽ cần những phương thức này, nhưng cách thực hiện phương thức sẽ khác nhau tùy vào giải pháp lựa chọn. Khi đó, chúng ta sẽ sử dụng tính chất abstraction để viết ra một Class kiểu như sau:

class BaseModel(ABC):
    """Abstract Model class that is inherited to all models"""
    def __init__(self, cfg):
        self.config = Config.from_json(cfg)
    @abstractmethod
    def load_data(self):
        pass
    @abstractmethod
    def build(self):
        pass
    @abstractmethod
    def train(self):
        pass
    @abstractmethod
    def evaluate(self):
        pass

Tất cả các phương thức trong class này đều chưa được chi tiết các làm việc (không có body), chúng ta mới chỉ khai báo chúng.

Tiếp theo, với mỗi một giải pháp đưa ra để thử nghiệm, chúng ta sẽ viết chúng thành một Class riêng, kế thừa lại abstract class này. Các inheritance class (Child Class) sẽ chi tiết cách làm việc của các phương thức trong abstract class (Parent Class). Tất nhiên, Child Class cũng sẽ có thêm các phương thức của riêng nó.

Ví dụ dưới đây, Class Unet sẽ kế thừa Class BaseModel:

class UNet(BaseModel):
    def __init__(self, config):
       super().__init__(config)
       self.base_model = tf.keras.applications.MobileNetV2(input_shape=self.config.model.input, include_top=False)
       . . .
    def load_data(self):
        self.dataset, self.info = DataLoader().load_data(self.config.data )
        self._preprocess_data()
        . . .
    def build(self):
        . . .
        self.model = tf.keras.Model(inputs=inputs, outputs=x)
    def train(self):
        self.model.compile(optimizer=self.config.train.optimizer.type,
                           loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
                           metrics=self.config.train.metrics)
        model_history = self.model.fit(self.train_dataset, epochs=self.epoches,
                                       steps_per_epoch=self.steps_per_epoch,
                                       validation_steps=self.validation_steps,
                                       validation_data=self.test_dataset)
        return model_history.history['loss'], model_history.history['val_loss']
    def evaluate(self):
        predictions = []
        for image, mask in self.dataset.take(1):
            predictions.append(self.model.predict(image))
        return predictions

Chú ý là ở trong Child Class, bên trong hàm tạo init, chúng ta phải gọi lệnh super().init() để thực hiện hàm tạo của Parent Class (super() là đại diện của Parent Class).

Việc có nhiều Child Classes kế thừa cùng một Parent Class, mỗi Child Class lại thực hiện các phương thức của Parent Class theo các cách khác nhau, chính là tính đa hình của OOP.

2.3 Instance Mothod, Class Method và Static Method

Instancemethod, classmethodstaticmethod cũng là các đặc tính thú vị của OOP. Hãy tìm hiểu thêm về nó một chút. Hãy xem xét ví dụ sau để hiểu rõ hơn về các loại phương thức này:

>>> class Dataset():
...     def im_load_data(self, x):
...         print("executing im_load_data(%s, %s)" % (self, x))
...     @classmethod
...     def cm_load_data(cls, x):
...         print("executing cm_load_data(%s, %s)" % (cls, x))
...     @staticmethod
...     def sm_load_data(x):
...         print("executing sm_load_data(%s)" % x)
...
>>> dataset = Dataset()

Dưới đây là cách đơn giản nhất để thực thi một phương thức:

>>> dataset.im_load_data('args')
executing im_load_data(<__main__.Dataset object at 0x7ff97a1bd280>, args)

Đây là một Instance Method, là phương thức phổ biến nhất. Một đối tượng (instance của class) được ngầm truyền thành tham số thứ nhất (self) của phương thức này. Vì vậy, mặc dù im_load_data cần hai tham số, nhưng dataset.im_load_data chỉ cần một tham số thôi. Và dataset.im_load_data không còn là hàm nguyên gốc ban đầu mà là một phiên bản đã được “bind” cho dataset:

>>> dataset.im_load_data
<bound method Dataset.im_load_data of <__main__.Dataset object at 0x7ff97a1bd280>>

Class Method là phương thức thuộc về cả Class. Khi thực thi, nó không dùng đến bất cứ một Instance nào của Class đó. Thay vào đó, cả Class sẽ được truyền thành tham số thứ nhất (cls) của phương thức này, tương tự như Instance Class.

>>> Dataset.cm_load_data('args')
executing cm_load_data(<class '__main__.Dataset'>, args)
>>> Dataset.cm_load_data
<bound method Dataset.cm_load_data of <class '__main__.Dataset'>>
>>> dataset.cm_load_data
<bound method Dataset.cm_load_data of <class '__main__.Dataset'>>

Một điều thú vị là Class Method cũng có thể gọi từ instance mà không gặp trở ngại gì (nhiều ngôn ngữ vẫn cho làm điều này kèm theo vài warning).

>>> dataset.cm_load_data('args')
executing cm_load_data(<class '__main__.Dataset'>, args)

Static Method là một phương thức đặc biệt, nó không sử dụng bất cứ thứ gì liên quan đến Class hay Instance của Class đó. Cả Self hay Cls đều không xuất hiện trong tham số của loại phương thức này. Và Static Method hoạt động không khác gì một hàm thông thường.

>>> dataset.sm_load_data('args')
executing sm_load_data(args)
>>> Dataset.sm_load_data('args')
executing sm_load_data(args)

Đối với Static Method, dù gọi dataset.sm_load_data hay Dataset.sm_load_data thì kết quả trả về vẫn là hàm ban đầu không hề được “bind” bất cứ một đối tượng nào:

>>> dataset.sm_load_data
<function Dataset.sm_load_data at 0x7ff97a093160>
>>> Dataset.sm_load_data
<function Dataset.sm_load_data at 0x7ff97a093160>

Static method, với sự đặc biệt của nó, được dùng rất hạn chế. Bởi vì nó không giống như instance method hay class method, nó không có bất cứ sự liên quan tới đối tượng gọi nó. Static method không phải là phương thức được sử dụng thường xuyên.

Tuy nhiên, nó vẫn tồn tại là có lý do của nó. Static method thường dùng để nhóm các hàm tiện ích lại với nhau (trong cùng một class). Nó không yêu cầu gì từ class đó, ngoại trừ việc tham chiếu khi được gọi.

Nhưng hàm như vậy không cho vào class nào cũng không vấn đề gì. Nhưng nhóm chúng trong class và gọi chúng thông quan instance hoặc class sẽ giúp chúng ta hiểu hơn về bối cảnh cũng như chức năng của chúng.

3. Documentation

Hay được hiểu là code phải có comments. Tác dụng của việc Comments code là không phải bàn cãi. Có 2 cách Comment code:

  • Comment bằng những lời giải thích tường mình
  • Comment bằng chính cách đặt tên hàm, tên biến, …

Hãy xem một ví dụ về Code không có Comment:

def n(self, ii, im):
    ii = tf.cast(ii, tf.float32) / 255.0
    im -= 1
    return ii, im

Đọc đoạn code trên, bạn có thấy khó hiểu không?

Bây giờ hãy thêm Comment vào cho nó:

def _normalize(self, input_image, input_mask):
        """ Normalise input image
        Args:
            input_image (tf.image): The input image
            input_mask (int): The image mask
        Returns:
            input_image (tf.image): The normalized input image
            input_mask (int): The new image mask
        """
        input_image = tf.cast(input_image, tf.float32) / 255.0
        input_mask -= 1
        return input_image, input_mask

Vẫn cùng 1 đoạn Code, nhưng sau khi thêm Comment, người đọc dễ dàng hiểu được mục đích của nó.

Cách Comment như trên trong Python gọi là docstrings. Chúng ta luôn luôn nên thêm Docstrings trước mỗi File, hàm, Module để chỉ ra mục đích của chúng. Có nhiều cách để định dạng Docstrings. Cách như trong ví dụ này là Style của Google, trong đó:

  • Dòng đầu tiên chỉ ra mục đích của đoạn code
  • Tiếp theo là giải thích các tham số truyền vào
  • Cuối cùng là giải thích kết quả trả về là gì?

Hai phần sau có thể bỏ qua nếu chúng không cung cấp nhiều giá trị.

4. Kết luận

Trong bài này, chúng ta đã khám phá một vài Best Practices khi Code cho dự án về AI, từ việc tổ chức dự án, sử dụng OOP, đến việc Comment trong Code. Theo cách đó, Code của chúng ta sẽ trở nên ràng mạch, rõ ràng, dễ đọc hiểu, dễ bảo trì, mở rộng và hạn chế phát sinh các lỗi tiềm ẩn trong quá trình vận hành.

Các bạn có thể tham khảo cấu trúc dự án mẫu tại đây.

Trong bài tiếp theo, chúng ta sẽ cùng thảo luận về cách thức xây dựng và tổ chức Source Code trong các dự án về AI. Mời các bạn đón đọc.

5. Tham khảo