Was das allseits beliebte Python vs. R vs. Julia vs. Scala betrifft …

Es ist bereits entschieden und die richtige Antwort ist YAML. Es hat jemals ML im Namen!

😉

(Bild oder Einbettung)

— Alex Gude (@alexgude.com) 27. Oktober 2024 um 17:23 Uhr

Warum kann ich nicht einfach Code schreiben?

Mir stehen 10 Jahre und halb so viele Jobs in den Bereichen Datenwissenschaft und maschinelles Lernen bevor. Egal was passiert, in jeder Rolle erfinde ich eine Programmiersprache auf Foundation von YAML neu, um Modelle für maschinelles Lernen zu trainieren.

Am Anfang steht ein Drehbuch.

import pandas as pd
from sklearn.linear_model import LogisticRegression

df = pd.read_csv("dataset.csv", parse_dates=("timestamp"))
y = df("label")
ts = df("timestamp")
X = df.drop(columns=("label", "timestamp"))
train_mask = ts < "2022-01-01"
test_mask = ts >= "2022-01-01"

X_train, y_train = X(train_mask), y(train_mask)
X_test, y_test = X(test_mask), y(test_mask)

mannequin = LogisticRegression(C=10, random_state=666)
mannequin = mannequin.match(X_train, y_train)

Sie stellen fest, dass Sie Parameter im Code manuell ändern, additionally verschieben Sie die Parameter in eine Befehlszeilenschnittstelle (CLI).

import typer

app = typer.Typer()

@app.command()
def major(filename: str, start_date: str, end_date: str):
    df = pd.read_csv(filename, parse_dates=("timestamp"))
    y = df("label")
    ts = df("timestamp")
    X = df.drop(columns=("label", "timestamp"))
    train_mask = ts < start_date
    test_mask = ts >= end_date
    
    X_train, y_train = X(train_mask), y(train_mask)
    X_test, y_test = X(test_mask), y(test_mask)
    
    mannequin = LogisticRegression(C=10, random_state=666)
    mannequin = mannequin.match(X_train, y_train)


# if __name__ == "__main__":
#     app()

Sie stellen jedoch fest, dass Sie ständig Parameter hinzufügen und Schaltpunkte manuell entwerfen.

from sklearn.ensemble import RandomForestClassifier

def get_model(model_type: str):
    if model_type == "lr":
        return LogisticRegression(C=10, random_state=666)
    elif model_type == "rf":
        return RandomForestClassifier(random_state=666)

def get_train_data(train_start_date: str, train_end_date: str):
    ...

def get_test_data(test_start_date: str, test_end_date: str):
    ...

Sie haben jetzt zu viele CLI-Parameter, um sie im Kopf zu behalten, und Du kannst dich nicht erinnern Welche davon hast du gestern bei diesem Lauf verwendet? Sie kommen additionally auf die kluge Idee, alle Ihre Parameter in einer einzigen Konfigurationsdatei zusammenzufassen. Sie entscheiden sich für YAML, weil es einfacher zu verwenden ist als JSON, und Sie wissen immer noch nicht wirklich, was TOML ist.

Ihre von Hand entworfenen Schaltpunkte sind nun tatsächlich Knotenpunkte in einem großen Ganzen DAG. Sie möchten maximale Flexibilität und treiben die Konfigurationssprache mit benutzerdefinierten Konstruktoren an ihre Grenzen Python-Klassen dynamisch instanziieren Und Importieren Sie andere Yaml-Dateien.

Sie lehnen sich zurück und staunen über die Schönheit der deklarativen Konfiguration.

mannequin:
  !Load 
  cls: sklearn.ensemble.RandomForestClassifier
  params:
    n_estimators: 3000
    random_state: 666

information: 
  practice: !Embody ../information/train_config.yaml
  take a look at: !Embody ../information/test_config.yaml

Sie geben Ihr wunderschönes Konfigurations-Framework an den Relaxation des Groups weiter, und niemand hat eine Ahnung, was zum Teufel los ist.

Hoppla! Es stellt sich heraus, dass Sie eine vollwertige Programmiersprache ohne moderne Vorteile erstellt haben. Globale YAML-Knoten werden in mehreren Dateien verwendet, ohne dass Referenzen nachverfolgt werden können. Die Typvalidierung ist minimal. Die IDE und mypy sind sich all dieser Python-Referenzen, die in YAML lauern, nicht bewusst. Und am Ende schreiben wir nicht-pythonischen Code, der für die Arbeit mit YAML gezwungen wurde.

Vor ein paar Jahren habe ich angefangen, die ganze Sache mitzureden.

Es struggle leicht, selbstgefällig zu predigen, dass die Leute einfach nur Code schreiben sollten. Einfach verwenden pydantisch statt YAML! Ich habe das sogar in einer früheren Rolle getan.

Wie alles beim Programmieren ist das leichter gesagt als getan. Obwohl alles typvalidiertes, auf Pydantic basierendes Python struggle, haben wir irgendwie immer noch viel zu viel Zeit damit verbracht, den Code in das von uns erstellte Konfigurationsframework einzupassen.

Warum kann ich nicht einfach Code schreiben?

Was ist das Besondere an ML?

Endlich wurde mir klar, warum es schwierig ist, einfach nur Code zu schreiben. Es gibt drei Hauptanforderungen für ein ML-Konfigurationssystem:

1. Die Möglichkeit, im gesamten DAG unterschiedlichen Code aufzurufen.

ML ist Forschung. Es ist Experimentieren. Es erfordert Iteration und Flexibilität.

Ich möchte verschiedene Klassifikatoren, verschiedene Verlustfunktionen, verschiedene Vorverarbeitungsmethoden usw. ausprobieren… Der einfachste Weg, dies zu unterstützen, besteht darin, das Schreiben und Aufrufen von Code zuzulassen.

Wir möchten Parameter nicht tief in einer verschachtelten DAG manuell ändern, um mit Dingen zu experimentieren. Wir definieren lieber alle unsere Parameter zu Beginn des DAG und überlassen den Relaxation dann dem DAG-Code.

2. Lazy-Instanziierung von Klassen in der DAG.

Wenn ich ein vorab trainiertes Modell verfeinere, möchte ich nicht das gesamte vorab trainierte Modell herunterladen müssen, wenn ich meine Konfiguration definiere.

# Some fast imports

import logging
import warnings

import transformers
from pydantic import BaseModel, ConfigDict
from transformers import AutoModelForSequenceClassification, PreTrainedModel

transformers.logging.set_verbosity(logging.ERROR)
warnings.filterwarnings("ignore", class=UserWarning, module="pydantic.*")
warnings.filterwarnings("ignore", class=UserWarning, module="tqdm.*")

Im folgenden Code wird das gesamte BERT heruntergeladen, wenn ich mein Konfigurationsobjekt definiere. Sie können sich schlimmere Szenarien vorstellen, bei denen die Instanziierung eines Modells die Zuweisung großer Mengen an GPU-Speicher erfordert. Wir sollten dies nicht tun, wenn unsere Konfiguration definiert ist. Dies ist besonders schlimm, wenn wir einen verteilten Trainingsjob ausführen und warten müssen, bis wir uns auf einem bestimmten Knoten befinden, bevor wir das Modell instanziieren.

# Dangerous
class TrainingConfig(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    mannequin: PreTrainedModel
    

config = TrainingConfig(
    # The mannequin weights get downloaded when defining the config.
    mannequin=AutoModelForSequenceClassification.from_pretrained(
        "bert-base-uncased"
    )
)

Daher sollte die Modellinstanziierung so lange wie möglich verzögert werden.

# Higher
class TrainingConfig(BaseModel):
    model_id: str 

config = TrainingConfig(model_id="bert-base-uncased")

...

# Someday Later
mannequin = AutoModelForSequenceClassification.from_pretrained(
    config.model_id
)

3. Verfolgung aller Konfigurations(hyper)parameter

Wir führen beim Coaching von ML-Modellen so viele Experimente durch, dass wir für Experiment-Tracker wie diesen bezahlen müssen Gewichte und Voreingenommenheiten. Entscheidend für die Experimentverfolgung ist die Verfolgung der Änderungen zwischen verschiedenen Trainingsläufen. Der einfachste Weg, dies in einem Device wie W&B zu tun, besteht darin, die gesamte Konfiguration in ein Wörterbuch einzutragen und das Diktat für einen bestimmten Lauf zu protokollieren.

Es stellt sich heraus, dass diese Monitoring-Anforderung im direkten Widerspruch dazu steht, „nur Code zu schreiben“.

Es ist ganz einfach, einfach den Code zu schreiben, um ein Modell in PyTorch zu instanziieren:

import torch 

class MyModel(torch.nn.Module):
    def __init__(
        self, 
        num_features: int, 
        num_classes: int,
        hidden_size: int = 128
    ):
        tremendous().__init__()
        self.num_features = num_features
        self.num_classes = num_classes
        self.hidden_size = hidden_size
        self.layers = torch.nn.Sequential(
            torch.nn.Linear(num_features, self.hidden_size),
            torch.nn.ReLU(),
            torch.nn.Linear(self.hidden_size, num_classes)
        )
    
    def ahead(self, x):
        return self.layers(x)
    
mannequin = MyModel(1_000, 16)

Aber jetzt kann ich die Konstruktorargumente des Modells nicht verfolgen.

Ein Nebeneffekt der Sicherstellung der Nachverfolgbarkeit unserer Konfiguration besteht darin, dass wir einen Anschein von Reproduzierbarkeit erhalten, da unsere Konfiguration in etwas wie JSON oder YAML serialisierbar sein muss. Ein Grund, warum YAML so beliebt ist, ist, dass wir nichts serialisieren müssen!

Warum können wir YAML nicht von ML trennen?

YAML löst alle unsere Anforderungen.

  1. Wir können benutzerdefinierte Konstruktoren schreiben, um beliebigen Code in unserer DAG aufzurufen.
  2. Wenn wir unsere Konfiguration instanziieren, wird kein Python-Code instanziiert
  3. YAML ist leicht nachverfolgbar und reproduzierbar.

Das Downside ist natürlich alles, was ich eingangs erwähnt habe.

Was sind die Lösungen?

Wenn Sie mit der Umgestaltung Ihrer gesamten Codebasis einverstanden sind, sollten Sie dafür sorgen, dass alle Ihre Klassen ein einziges Konfigurationsobjekt als Argumente verwenden. Dadurch werden alle drei Probleme gelöst und Sie erhalten schöne, strukturierte Nicht-YAML-Konfigurationen.

from typing import Kind

import torch

class MyModelConfig(BaseModel):
    num_features: int
    num_classes: int
    hidden_size: int = 128

class MyModel(torch.nn.Module):
    def __init__(self, config: MyModelConfig):
        tremendous().__init__()
        self.config = config
        self.layers = torch.nn.Sequential(
            torch.nn.Linear(config.num_features, config.hidden_size),
            torch.nn.ReLU(),
            torch.nn.Linear(config.hidden_size, config.num_classes)
        )
    
    def ahead(self, x):
        return self.layers(x)

class TrainingConfig(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    model_cls: Kind(MyModel)
    model_cfg: MyModelConfig


# No fashions are instantiated within the config.
config = TrainingConfig(
    model_cls=MyModel,
    model_cfg=MyModelConfig(num_features=100, num_classes=10)
)
# Lazy-load the mannequin.
mannequin = config.model_cls(config.model_cfg)

Sie können sogar versuchen, etwas ausgefallener zu sein und die Klassenkonfigurationen innerhalb der Klasse zu definieren, sodass sie immer miteinander gekoppelt sind. Dies macht es einfach, die Klasse dynamisch zu instanziieren, allein basierend auf der Klassenkonfiguration.

import importlib

def load_config(config: BaseModel):
    module = config.__class__.__module__
    guardian = config.__class__.__qualname__.break up(".")(0)
    return getattr(importlib.import_module(module), guardian)(config)


class MyModel(torch.nn.Module):
    class Config(BaseModel):
        num_features: int
        num_classes: int
        hidden_size: int = 128
    def __init__(self, config: Config):
        tremendous().__init__()
        self.config = config
        self.layers = torch.nn.Sequential(
            torch.nn.Linear(config.num_features, config.hidden_size),
            torch.nn.ReLU(),
            torch.nn.Linear(config.hidden_size, config.num_classes)
        )
    
    def ahead(self, x):
        return self.layers(x)

class TrainingConfig(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    model_cfg: BaseModel


config = TrainingConfig(
    model_cfg=MyModel.Config(num_features=100, num_classes=10)
)
mannequin = load_config(config.model_cfg)

Das Downside bei diesem Ansatz besteht natürlich darin, dass die gesamte Codebasis umgestaltet werden muss. Dies wird auch besonders unangenehm, da dafür eine Konfiguration erstellt werden muss alleseinschließlich Klassen, die Sie lieber nicht träge instanziieren möchten.

Was ich finde, ist, dass die Leute häufig eine Umgestaltung ihrer Codebasis vermeiden, indem sie separate Konfigurationen erstellen, die ihren Klassenkonstruktorargumenten entsprechen.

class MyModelConfig(BaseModel):
    num_features: int
    num_classes: int
    hidden_size: int = 128

class MyModel(torch.nn.Module):
    def __init__(
        self, 
        num_features: int, 
        num_classes: int,
        hidden_size: int = 128
    ):
        ...

Die Codeduplizierung hier ist schmerzhaft.

Eine bessere Lösung bewirkt drei Dinge:

  1. Kein Refactoring des Klassenkonstruktors. Nennen Sie Klassen so, wie sie aufgerufen werden würden.
  2. Verfolgen Sie alle Argumente des Klassenkonstruktors an einem serialisierbaren, zentralen Ort.
  3. Irgendwie Auch Erlauben Sie Lazy-Instantion, auch wenn die Klassenkonstruktoren aufgerufen werden.

Schritt 3 kann übersprungen werden, wenn Sie eine Umgestaltung durchführen, um sicherzustellen, dass alle Konstruktoren des Modells „leichtgewichtig“ sind und nichts wie das Herunterladen riesiger Gewichte, die Zuweisung von viel GPU-Speicher usw. erfordert… aber das stellt große Einschränkungen für Ihre Codebasis dar.

Gibt es eine Lösung für alle 3 Schritte? Ich habe es noch nicht gesehen, aber ich habe einige Ideen. Ich brauche nur etwas mehr Zeit, um sie langsam zu laden.

Von admin

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert