返回资讯中心
外部精选
大数据
#数据科学#机器学习

7 Advanced Python Design Patterns for Data Scientists

Learn seven design patterns for data science code, from Strategy and Adapter to Command and Proxy, each matched to the pressure it solves, with the Python shape it takes and the caveat that keeps it honest.

KDnuggetsNahla Davies2026/10/26 · 今天23 分钟阅读

以下正文同步自 KDnuggets,版权归原站所有,已转换为易读排版。

An experiment runner that picks scoring with if metric == "accuracy" grows a new branch for every metric the team adopts. Strategy removes the branching by making the scoring behavior a value the caller passes in:

def evaluate(y_true, y_pred, scorer): return scorer(y_true, y_pred)

evaluate(y_true, y_pred, accuracy) # 0.6 evaluate(y_true, y_pred, recall_pos) # 0.667

Both calls run against the same five predictions, but the runner never learns which policy it executed, and that ignorance is the payoff. Each scorer can now be tested on its own, and adding a new one means writing a function rather than editing the experiment. Strategy also gets confused with Command often enough that the line is worth drawing here. Strategy gives the caller interchangeable ways of doing the same job right now, while Command, further down this list, packages one operation with its arguments so the work can run later or somewhere else.

The caveat comes from Python itself. A strategy with no state should stay a plain callable like the scorers above, because a class only earns its place once configuration or related methods need to travel with the behavior.

Evaluation code that checks which library produced a model has already leaked an interface problem downstream. The fix is one local contract, written as a typing.Protocol, plus a thin wrapper around anything that fails to match it:

class Predictor(Protocol): def predict_proba(self, X) -> list: ...

class LegacyAdapter: def init(self, model): self._model = model

def predict_proba(self, X): scores = self._model.score_rows(X) return [[1 - s, s] for s in scores]

The legacy model exposes score_rows() and returns bare scores, so the adapter reshapes them into the two-column probability format everything downstream expects. Once that wrapper exists, switching libraries no longer means rewriting the evaluation layer. Protocol carries a warning of its own here. Its checking is static, and decorating it with @runtime_checkable only makes isinstance() test for attribute presence, meaning a class whose predict_proba takes the wrong arguments still passes the check.

The bigger danger sits one level deeper, at the semantics. An adapter translates an interface and nothing more, so differences in class order, decision thresholds or calibrated versus uncalibrated probabilities pass straight through it. An adapter that papers over those is a bug generator wearing a compatibility costume.

Closely related experiments tend to copy their orchestration from each other, and the copies drift. Template Method fixes the lifecycle once, in a base class built on abc, and lets subclasses fill in the steps that legitimately vary:

class Experiment(ABC): def run(self): self.load() self.fit() self.evaluate() self.save()

@abstractmethod def fit(self): ...

A tree-model subclass implements fit() and inherits everything else, so running it executes load, fit, evaluate and save in that order, every time, for every variant. Nobody overrides run(), because the stable skeleton is the entire point. The orchestration that used to be copied between sibling experiments now lives in exactly one place, and consistent cleanup and reporting come along for free.

What the pattern gains through inheritance it also pays for through inheritance, though. Every variant stays coupled to the base lifecycle. The moment steps need to change independently or combine freely, they belong in Strategy objects composed at runtime rather than in a deeper subclass tree.

A training loop that calls the experiment tracker, the console, the dashboard and the early-stopping check by name has four reasons to change that have nothing to do with training. Observer flips the dependency, so the loop emits one immutable event and a list of callbacks decides what happens next:

@dataclass(frozen=True) class EpochEnded: epoch: int loss: float

for cb in observers: cb(EpochEnded(epoch, loss))

Two observers with separate jobs show why this works. A console logger formats each event into a line, while a best-score tracker keeps the lowest loss it has seen, and neither knows the other exists. Adding a dashboard writer later just means appending one callable to the list, with the training loop untouched. Freezing the event matters more than it looks, too, since any observer that tries to mutate it gets a FrozenInstanceError instead of silently corrupting what the next observer receives.

The trouble with Observer arrives at scale. A visible list of three callbacks is easy to reason about, but event order, error handling and per-event cost all need explicit answers before this grows into an invisible event bus nobody can trace.

Some runs should not execute the moment somebody thinks of them. A sweep might need to wait for overnight capacity, and a run that produced an interesting result last week might deserve an exact repeat today. Command turns the request itself into an object built from a frozen dataclass:

@dataclass(frozen=True) class TrainModel: dataset_id: str model_name: str config_id: str

def execute(self, runner): return runner(self)

The object serializes cleanly through asdict(), so it can be logged, queued, retried or shipped to a worker on another machine. The frozen flag, meanwhile, guarantees the request that executes is the request that was recorded. What should run stops depending on when and where it runs, which is the real point. Just don't let the word "reproducible" do more work than it has earned. A Command captures a request, not an environment. Dataset versions, code versions, random seeds and platform assumptions still need explicit identifiers inside the object, or the replay will produce a different model under the same name.

When scoring one configuration in a notebook requires five imports and a setup dance, every consumer of the pipeline is paying an integration tax. Facade collects the subsystem behind one task-level method:

class ExperimentFacade: def train_and_report(self, config): cleaned = self._preprocessor.clean(config) model = self._trainer.fit(cleaned) return self._reporter.report(model)

The facade performs none of the real work; it delegates to focused components and only decides the order, which keeps it three lines long and leaves the components testable on their own. It is also the mirror image of the Adapter from earlier. An adapter makes one incompatible object fit a local interface, while a facade simplifies access to a whole subsystem that already works.

The caller gets a stable entry point out of the deal, and the internals stay free to change behind it. The risk is appetite. A facade that starts owning validation rules, retry policy and state stops being a doorway and becomes the new monolith, so split it while it is still thin.

A three-gigabyte model loaded at import time makes every notebook restart and every service deploy pay for a resource that might never be used. Proxy defers the cost while preserving the contract consumers already code against:

class LazyModelProxy: def init(self): self._model = None

def predict(self, X): if self._model is None: self._model = RealModel() return self._model.predict(X)

The real model loads on the first predict() and never again, while consumers keep calling the same method they always did, unaware a boundary exists. The same shape guards an expensive dataset read just as well.

What the pattern will not do is make the operational decisions for you. Cache invalidation, thread and process safety, and behavior when the load fails all need answers, written down, before the proxy ships. A proxy that silently serves a stale artifact is strictly worse than an explicit slow load, because the slow load at least tells the truth.

Three questions separate a useful pattern from an impressive class diagram. What changes repeatedly? What contract has to stay stable while it changes? And what new failure mode does the abstraction itself introduce? Answer those and the choice mostly makes itself. Behavior that keeps varying points to Strategy, a mismatched interface to Adapter, and a fixed lifecycle with a few movable steps to Template Method. Multiplying listeners want Observer, while work that has to travel belongs in a Command, a noisy subsystem behind a Facade, and an expensive resource behind a Proxy.

All of these are code-level boundaries, and the discipline around them lives at the project level, where the step-by-step guide to structuring a data science project is the natural next read. Once the pressures move past code shape into feature stores, training-serving skew and model cascades, design patterns for MLOps picks up where object design stops.

The measure of success never changes, though. A pattern has earned its place when the caller got simpler and the boundary got easier to test. The class diagram is nobody's deliverable.

Nahla Davies is a software developer and tech writer. Before devoting her work full time to technical writing, she managed—among other intriguing things—to serve as a lead programmer at an Inc. 5,000 experiential branding organization whose clients include Samsung, Time Warner, Netflix, and Sony.

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →