6. Modules, Packages and Object-Oriented DesignIntroduction and ContextArtificial intelligence and machine-learning systems rarely remain small scripts. A prototype may begin as a notebook containing data loading, preprocessing, model training, evaluation, and prediction code. As the system develops, these responsibilities must be separated, tested, reused, and maintained by several people. Python provides two complementary mechanisms for achieving this structure: modules and packages, which organize code physically, and object-oriented design, which organizes data and behaviour conceptually. A well-designed ML application might contain modules for data ingestion, feature engineering, model definition, experiment tracking, evaluation, and deployment. Classes can represent datasets, models, pipelines, metrics, and services. The quality of this design influences reproducibility, testing, collaboration, deployment, and the ease with which an algorithm can later be replaced. This chapter develops a systematic understanding of imports, packages, classes, inheritance, composition, polymorphism, abstract interfaces, dataclasses, and design principles. The emphasis is not merely on syntax, but on making sound design decisions in professional AI and ML software. Learning ObjectivesAfter studying this chapter, the learner should be able to: 1. Explain the roles of modules, packages, imports, and namespaces in Python. 2. Design a package structure for a medium-sized AI or ML application. 3. Create classes that combine state and behaviour appropriately. 4. Distinguish inheritance, composition, aggregation, and dependency injection. 5. Apply polymorphism and abstract interfaces to interchangeable ML components. 6. Use dataclasses for configuration and value-oriented objects. 7. Evaluate designs using cohesion, coupling, SOLID principles, and related practices. 8. Identify common architectural, testing, security, and maintainability risks. From Scripts to Software SystemsA module is a Python file containing definitions and executable statements. If `metrics.py` contains a function named `accuracy`, another file can use `from metrics import accuracy`. The module establishes a namespace, so names defined inside it do not automatically collide with names in the importing file. A package is a structured collection of modules. In conventional Python packaging, a directory containing an `__init__.py` file is recognized as a package. Modern namespace packages can also exist without that file, but an explicit `__init__.py` remains useful for clarity and package initialization. A package may contain subpackages, creating a hierarchy such as `mlproject.data`, `mlproject.models`, and `mlproject.evaluation`. An import performs more than textual inclusion. Python locates a module through its import path, creates a module object, executes its top-level code once, and caches the result in `sys.modules`. Subsequent imports normally reuse that cached object. This explains why expensive top-level operations, such as loading a large model during import, can slow programs and make tests difficult. Common import forms include: ```python import numpy as np from pathlib import Path from mlproject.metrics import rmse import mlproject.models as models ``` The first form makes the namespace explicit (`np.linalg`), while the second imports a selected name. Explicit imports generally improve readability. Wildcard imports such as `from module import *` obscure the origin of names and are therefore poor practice in production code. The module variable `__name__` is useful when a file serves both as an importable module and as a directly executable script: ```python def main(): print("Training started") if __name__ == "__main__": main() ``` The guarded block prevents `main()` from running merely because another module imports the file. For larger applications, a dedicated command-line entry point is often preferable. Designing Packages for ML WorkA package should reflect meaningful responsibilities rather than arbitrary file sizes. A possible structure is: ```text mlproject/ pyproject.toml src/ mlproject/ __init__.py data/ __init__.py loaders.py validation.py features/ engineering.py models/ base.py linear.py tree.py evaluation/ metrics.py reports.py pipelines/ training.py inference.py tests/ test_metrics.py test_loaders.py ``` The `src` layout helps prevent accidental imports from the project root during development. A `pyproject.toml` file records project metadata and dependencies and supports modern build tools. Tests should normally import the installed package rather than relying on the current working directory. Package design should follow cohesion and low coupling. Cohesion asks whether the elements of a module belong together. A module containing unrelated database access, image transformations, and command-line parsing has weak cohesion. Coupling asks how strongly one component depends on another. A training pipeline that directly constructs a particular database client, scaler, and model is more difficult to change than one receiving these components through interfaces or parameters. The `__init__.py` file may expose a carefully selected public API: ```python mlproject/models/__init__.pyfrom .linear import LinearRegressor __all__ = ["LinearRegressor"] ``` However, re-exporting every internal symbol creates a large and unstable API. A professional package distinguishes public interfaces from implementation details and avoids circular imports. Circular imports often indicate that responsibilities have been placed in the wrong modules; moving shared abstractions into a lower-level module is usually better than relying on import tricks. Classes as Models of ResponsibilityA class defines a type whose objects combine data and operations. Its constructor establishes valid initial state, while methods express behaviour that belongs to the object. In ML software, a class may represent a trained model, a feature transformer, a data source, or an experiment configuration. ```python class Standardizer: def __init__(self): self.mean_ = None self.scale_ = None def fit(self, X): self.mean_ = X.mean(axis=0) self.scale_ = X.std(axis=0) return self def transform(self, X): if self.mean_ is None: raise RuntimeError("The transformer must be fitted first") return (X - self.mean_) / self.scale_ ``` The trailing underscore convention often indicates attributes learned during fitting. It communicates that these values are not supplied as hyperparameters but are derived from data. A class should protect its invariants: `transform` should not silently operate with an unfitted object. An instance method receives `self` and operates on object state. A class method receives `cls` and is appropriate for alternative constructors or class-level configuration. A static method receives neither and is useful only when a function is conceptually grouped with a class but does not depend on its state. These mechanisms should not be used merely to make code appear object-oriented. Encapsulation does not mean that all attributes must be private in a language-enforced sense. Python uses conventions such as `_internal_name` and name mangling for `__name`. More important is controlling the public contract: callers should use stable methods and properties rather than depend on implementation details. Dataclasses and Value-Oriented DesignMany ML objects primarily hold configuration rather than complex behaviour. A dataclass reduces repetitive constructor, representation, and comparison code: ```python from dataclasses import dataclass, field @dataclass(frozen=True) class TrainingConfig: learning_rate: float = 0.001 epochs: int = 20 features: tuple[str, ...] = field(default_factory=tuple) ``` `frozen=True` makes instances effectively immutable, which is valuable for configurations used as experiment identifiers or dictionary keys. `default_factory` is essential for mutable defaults such as lists and dictionaries; writing `features=[]` would cause instances to share one mutable object. Dataclasses are most appropriate for records and value objects, not as a substitute for every domain class. Type annotations improve documentation and enable static analysis, but they do not by themselves enforce types at runtime. In systems handling untrusted inputs, explicit validation remains necessary. A configuration object can validate constraints in `__post_init__`: ```python @dataclass(frozen=True) class BatchConfig: batch_size: int = 32 def __post_init__(self): if self.batch_size <= 0: raise ValueError("batch_size must be positive") ``` Inheritance, Composition and InterfacesInheritance expresses an “is-a” relationship. A `RandomForestModel` may be a kind of `Predictor`. Subclasses inherit behaviour and can override methods. Inheritance is useful when a stable abstraction genuinely describes all subclasses, but deep hierarchies create fragile dependencies and make behaviour difficult to trace. Composition expresses a “has-a” relationship. A `TrainingPipeline` may have a data loader, feature transformer, and estimator. Composition usually provides greater flexibility because each component can be replaced independently. It is particularly effective in ML, where preprocessing, model selection, and evaluation strategies frequently change. Polymorphism allows code to use different objects through a common contract. An inference service should not need separate control flow for every model type if each model supplies `predict`: ```python class InferenceService: def __init__(self, predictor): self.predictor = predictor def classify(self, records): return self.predictor.predict(records) ``` The service depends on behaviour, not on a concrete model class. This is an example of dependency inversion: high-level policy is separated from low-level implementation. Python supports informal protocols through duck typing: an object is suitable if it provides the required operations. For stronger documentation and tooling, `typing.Protocol` can express a structural interface: ```python from typing import Protocol class Predictor(Protocol): def predict(self, X): ... ``` An abstract base class is useful when a family of classes must follow an explicit contract or share implementation: ```python from abc import ABC, abstractmethod class Regressor(ABC): @abstractmethod def fit(self, X, y): pass @abstractmethod def predict(self, X): pass ``` A subclass that does not implement both methods cannot be instantiated. Protocols emphasize capabilities and flexible structural typing; abstract base classes emphasize a declared nominal hierarchy. The choice depends on whether shared implementation, registration, runtime checks, or loose substitutability is most important. A Reusable Pipeline DesignA useful design for supervised learning separates stages while allowing them to cooperate through narrow interfaces: ```python class Pipeline: def __init__(self, transformer, estimator): self.transformer = transformer self.estimator = estimator def fit(self, X, y): transformed = self.transformer.fit_transform(X) self.estimator.fit(transformed, y) return self def predict(self, X): transformed = self.transformer.transform(X) return self.estimator.predict(transformed) ``` The pipeline uses composition. The transformer and estimator may be replaced with different implementations, provided they satisfy the expected methods. In production, a more explicit protocol could define `fit`, `transform`, `fit_transform`, and `predict`, while validation would check that training and prediction use compatible schemas. This design also illustrates separation of concerns. The transformer handles representation; the estimator handles learning; the pipeline coordinates the workflow. Evaluation should generally remain outside the estimator so that metrics and reporting can vary without modifying model code. Design Principles for Professional PythonSeveral principles help evaluate object-oriented designs. Single Responsibility means that a class or module should have one coherent reason to change. A `ModelTrainer` should not also format HTML reports and manage cloud credentials. Open–Closed suggests that components should be open to extension but closed to repeated modification; a new estimator should implement an interface rather than require edits throughout the pipeline. Liskov Substitution requires that a subtype genuinely honour the expectations of its base abstraction. A class whose `predict` method changes the meaning or required inputs of the contract is not a safe substitute. Interface Segregation favours small, focused interfaces. A read-only predictor should not be forced to implement data deletion and model retraining. Dependency Inversion encourages high-level code to depend on abstractions rather than concrete infrastructure. These principles are guides, not laws. Excessive abstraction can make a small system harder to understand. A design should earn its layers through real variation, testing needs, or organizational boundaries. “Over-engineering” is a genuine risk: factories, registries, and elaborate hierarchies are harmful when there is only one stable implementation. Other important practices include explicit dependency injection, meaningful exception types, deterministic random seeds where reproducibility matters, and clear ownership of resources. A class that opens files, database connections, or model sessions should define how those resources are closed, often through a context manager. Detailed Applied Case: A Model-Serving PackageBackgroundConsider an illustrative organization developing a credit-risk prediction service. Its initial prototype is a notebook that reads a CSV file, fills missing values, trains a logistic regression model, and prints accuracy. A production service must accept API requests, apply exactly the training transformations, produce predictions, record model versions, and support replacement of the model. ProblemThe prototype has duplicated preprocessing code in training and inference. A change to a missing-value rule updates one path but not the other. The service also imports a large model at module import time, making tests slow and causing failures when the model file is absent. Finally, the API directly constructs a specific estimator, so experimenting with a calibrated tree model requires changing request-handling code. AnalysisThe central problems are high coupling, duplicated responsibility, and an unstable boundary between application logic and model implementation. The team identifies three contracts: a transformer that can fit and transform, an estimator that can fit and predict, and a model repository that loads a versioned artifact. Configuration should be separate from operational services. ApproachThe team creates the following conceptual package: ```text risk_service/ domain.py # protocols and domain types config.py # frozen dataclasses preprocessing.py # feature transformation estimators.py # concrete predictors repository.py # artifact loading service.py # application use case api.py # transport adapter ``` `service.py` receives a predictor and a schema validator through dependency injection. The API layer converts JSON into domain records but does not know whether the predictor is logistic regression or a tree ensemble. Training constructs a pipeline from a transformer and estimator, saves the fitted object with an explicit version, and records the feature schema. Inference loads the selected version only when the service is initialized, not whenever the module is imported. Unit tests use a fake predictor implementing the same protocol. This makes it possible to test request validation and response formatting without loading a real model. Integration tests verify that a saved pipeline produces predictions from the same feature order used during training. OutcomeThe illustrative redesign removes duplicated transformation logic and allows estimator replacement without changing the API. Tests become faster because dependencies are injected. Versioned artifacts and schemas improve reproducibility. The design does not eliminate all risk: model quality, data drift, fairness, and secure artifact storage still require separate controls. Learning PointsThe case demonstrates that modularity is not simply a directory arrangement. It requires explicit boundaries, coherent responsibilities, substitutable components, controlled imports, and tests that verify contracts. In ML systems, preserving the training–inference transformation boundary is as important as selecting an algorithm. Common Errors, Risks and Governance ConcernsA frequent error is confusing inheritance with reuse. Subclassing merely to reuse a utility method can produce an incorrect “is-a” relationship; composition or a standalone function is safer. Another error is placing executable work at module scope. Import-time network calls, file reads, or model loading reduce reliability and can create security concerns. Mutable class attributes can accidentally share state among instances. Mutable dataclass defaults can do the same. Catching broad `Exception` values hides defects and makes operational diagnosis difficult. Public package APIs that expose internal modules create compatibility obligations. Relative imports that work only from one working directory reveal packaging problems. Serialization deserves particular caution. Python pickle-based formats can execute arbitrary code when loading untrusted data. Model artifacts should come from trusted, integrity-checked sources, and loading services should be isolated according to organizational security requirements. Secrets must not be stored in source files or configuration objects committed to version control. AI and ML applications also raise governance issues. A modular architecture should make it possible to record model version, training data lineage, feature definitions, evaluation results, and approval status. Interfaces can support audit logging, but logging must avoid exposing personal or sensitive data. Replacing a model through polymorphism does not guarantee fairness or validity; every replacement requires appropriate validation, monitoring, and human accountability. Current Directions and Best PracticesContemporary Python projects increasingly use `pyproject.toml`, static type checking, automated formatting, linting, continuous integration, and dependency lock or constraint files. Protocols, generics, and dataclasses make contracts clearer, while dependency-injection patterns support testing and deployment across local, cloud, and edge environments. For ML teams, the emerging emphasis is on production modularity: reusable pipelines, model registries, feature contracts, reproducible environments, and observability. A sound practice is to keep domain logic independent of web frameworks and cloud SDKs, then connect them through adapters. Another is to make configuration explicit and immutable where possible. Small modules, stable interfaces, contract tests, documentation of public APIs, and deliberate deprecation policies provide more value than elaborate class hierarchies. Chapter SummaryModules provide namespaces and packages organize related modules into maintainable systems. Imports should be explicit, controlled, and free from unnecessary import-time side effects. Classes combine state and behaviour, while dataclasses efficiently represent validated configuration and value objects. Inheritance is appropriate for genuine “is-a” relationships; composition is often better for replaceable ML components. Polymorphism, protocols, and abstract base classes allow pipelines and services to depend on contracts rather than concrete algorithms. Cohesion, low coupling, SOLID principles, dependency injection, testing, secure serialization, and model governance together support professional AI and ML software. The model-serving case shows how these ideas address duplicated preprocessing, rigid dependencies, and reproducibility risks. Key TermsModule: A Python file that defines a namespace containing code and data. Package: A structured collection of related Python modules and subpackages. Namespace: A mapping in which names are associated with objects. Import: The operation that locates, executes, and makes a module available. Cohesion: The degree to which elements of a component belong to one responsibility. Coupling: The degree of dependency between software components. Class: A definition describing the data and behaviour of a type of object. Instance: A concrete object created from a class. Encapsulation: The practice of controlling access to state through a defined interface. Dataclass: A class utility that generates common methods for data-oriented objects. Inheritance: A mechanism through which a subclass derives behaviour from a superclass. Composition: Building an object from other cooperating objects. Polymorphism: The ability to use different implementations through a common contract. Protocol: A structural type description specifying required operations. Abstract base class: A class that defines a contract and may prevent incomplete instantiation. Dependency injection: Supplying a component’s dependencies from outside the component. Interface: A set of operations and behavioural expectations exposed to clients. Namespace package: A package assembled across locations without necessarily requiring `__init__.py`. Model artifact: A saved representation of a trained model and, often, its associated pipeline. Dependency inversion: Designing high-level policy to depend on abstractions rather than concrete details. Review QuestionsShort Answer Questions1. What is the difference between a Python module and a package? 2. Why is the `if __name__ == "__main__":` guard useful? 3. When is a dataclass preferable to an ordinary class? 4. Distinguish inheritance from composition. 5. What problem does dependency injection address? Long Answer Questions1. Explain how imports are resolved and cached, and discuss why import-time side effects are undesirable. 2. Design and justify a package structure for an ML training and inference application. 3. Compare protocols and abstract base classes as mechanisms for defining component contracts. 4. Explain the SOLID principles using examples from ML pipelines. 5. Discuss how modular design contributes to reproducibility, testing, and model governance. Analytical / Application Questions1. A pipeline directly constructs a scaler, database client, and neural network inside one class. Analyze the design using cohesion, coupling, and dependency inversion, then propose a revision. 2. Two estimator subclasses implement `predict`, but one silently changes the feature order expected by the pipeline. Which substitutability principle is violated, and how should the contract be improved? 3. A production service loads a pickle model whenever its prediction method is called. Identify the architectural and security risks and recommend a safer modular design. 22. Multiple Choice Questions with AnswersQuestion 1What is the primary benefit of placing related Python modules in a package? A. It guarantees that all functions are globally visible B. It organizes namespaces and responsibilities into a coherent structure C. It prevents every form of circular dependency D. It removes the need for testing Correct Answer: B. It organizes namespaces and responsibilities into a coherent structure Question 2Why should expensive model loading generally not occur at module import time? A. Imports cannot execute Python statements B. It prevents classes from using inheritance C. It can slow tests and cause failures whenever the module is imported D. It makes type annotations invalid Correct Answer: C. It can slow tests and cause failures whenever the module is imported Question 3Which design best represents composition in an ML pipeline? A. A pipeline contains a transformer and an estimator as separate objects B. A subclass changes the name of its superclass C. A function imports every module with a wildcard D. A package contains an __init__.py file Correct Answer: A. A pipeline contains a transformer and an estimator as separate objects Question 4What is the main purpose of a Protocol in Python typing? A. To force all classes to share one implementation B. To describe required behaviour through structural typing C. To serialize a trained model securely D. To prevent object instantiation Correct Answer: B. To describe required behaviour through structural typing Question 5Which dataclass definition correctly avoids a shared mutable default? A. features: list = [] B. features: list = list() C. features: list = field(default_factory=list) D. features: list = None without any conversion Correct Answer: C. features: list = field(default_factory=list) Question 6A service accepts any object having a compatible predict method, regardless of its concrete class. This is an example of: A. Polymorphism B. Circular importing C. Name mangling D. Tight coupling Correct Answer: A. Polymorphism Question 7Which situation most clearly violates the Single Responsibility principle? A. A transformer scales numerical features B. A configuration dataclass stores training parameters C. A trainer loads data, trains models, sends emails, and formats reports D. An estimator implements fit and predict Correct Answer: C. A trainer loads data, trains models, sends emails, and formats reports Question 8Why is untrusted pickle-based model loading risky? A. Pickle cannot represent Python objects B. Loading may execute malicious code contained in the artifact C. Pickle always changes model accuracy D. Pickle prevents versioning Correct Answer: B. Loading may execute malicious code contained in the artifact Question 9In the case study, dependency injection primarily allowed the team to: A. Avoid defining any interfaces B. Replace predictors and use fakes in tests without changing API logic C. Remove the need for feature validation D. Make all models inherit from the API class Correct Answer: B. Replace predictors and use fakes in tests without changing API logic Question 10A subclass implements a base predictor’s method but requires a different input contract, breaking client expectations. Which principle is most directly threatened? A. Liskov Substitution Principle B. Interface Segregation Principle C. Open–Closed Principle D. Single Responsibility Principle Correct Answer: A. Liskov Substitution Principle
|