Hadi Gharibi
01/18/2025, 12:46 PMElijah Ben Izzy
01/18/2025, 5:43 PMinputs: training_data: pd.DataFrame
outputs: model: Model, metrics: dict[str, Any]
Then you’d effectively write the module so that it respects this interface (+ potentially more parameters). You would then take the driver and chain together the implementations of this interface:
import my_training_module
import my_eval_module
dr = driver.Builder().with_modules(my_training_module, my_eval_module, ...).build()
And the driver checks if you have all the pieces together, by traversing up to the inputs. So you define a DAG in a module that adheres to the interface, and merging is just putting them together in the driver! Often, if you’re building a platform, you’d be writing your own wrapper around driver. This can do simple validation, e.g. something like this:
def create_my_driver(training_module, eval_module, ...):
validate_training_module(training_module) # creates a temp driver, checks inputs
validate_eval_module(eval_module) # ditto
return driver.Builder().with_modules(training_module, eval_module, ...)
We found that this is much simpler to reason about than classes, which have an added layer of state that people almost always use. Hamilton + functions imply statelessness, which makes it simpler to manage/track/repro. I think this gest at (1) and (2) above.
I”m not 100% sure what you mean with (3) — mind drawing out what you’re trying to do?Elijah Ben Izzy
01/18/2025, 5:45 PMHadi Gharibi
01/18/2025, 6:12 PMfit predict and some other methods(based on the interface( I need the interface because the users can be responsible on their model validation and hp search without knowing about the rest of the system) and in fit and predict function they say what inputs they need to create the output.
for point number 3 since this idea has two phases of training and inference and in the prediction phase all the models can be trained in parallel, and in inference the dag dependencies will be the most important factor. that's why I was asking do I need two dags(one for train and one for inference) or if should I use when conditon.Elijah Ben Izzy
01/18/2025, 6:25 PMconfig.when — useful if there are a few pieces that vary between them
2. Use separate modules (E.G. train and inference ) — useful when there are more differences between them.
This post goes over the options — think it will be pretty relevant to you: https://blog.dagworks.io/p/feature-engineering-with-hamilton.
Just to be clear, if you had a specific decorator for this, what would it look like? Subdag, etc… can all help, but writing it down might make it clearer. I think that implementing a module with a certain shape will likely be enough, but you could probably create a custom decorator on top of other ones for your specific case, if the interface is constant/limited. Note this also could take in an object if you really want to — there are some options here. But yeah if you write out your ideal code I can help you figure out how to structure it with Hamilton (and how much you need to dig into how the DAG is constructed)!Hadi Gharibi
01/18/2025, 7:21 PMclass Base:
def __init__(
self,
*,
parents: list[str],
children: list[str],
obj: Any,
obj_type: Literal["model", "func"],
name: str = None,
):
"""Base class
Args:
parents (list[str]):
children (list[str]):
model (Any): Any model
name (str, optional): Name. Defaults to None.
"""
@abstractmethod
def fit(self, ) -> None:
"The input data should match the `parents` schema"
@abstractmethod
def predict(self, ) -> dict[str, DataFrame]:
"This function should match the `children` schema"
@abstractmethod
de hp_search(self, ):
...
def train(self, data: DataFrameDict):
self.fit(data)
def __call__(self, data: DataFrameDict) -> DataFrameDict:
return self.predict(**{name: data[name] for name in self.parents})
and based on this class I created 3 types which is transformation functions like ETLs, models and model collections. users based on their tasks inherit one of these 3 classes and implement their logic
like this:
class CTR(Model):
def __init__(self, model: Any):
super().__init__(parents=['user', 'price', 'competition',], children=['purchase'], model=model, name='ctr')
def fit(self, data):
X = pd.concat([data['user'], data['price'], data['competition']], axis=1)
self.model.fit(X, now_dict['purchase'].purchase)
def predict(self, user, price, competition):
X = pd.concat([user, price, competition], axis=1)
return {'purchase': pd.DataFrame(self.model.predict_proba(X)[:, 1], columns=['purchase'], index=X.index)}
ctr = CTR(model=ctr_model)
or for model collection something like this
model = ModelCollection(
models=[ctr, purchase_filter, freq, sev, p_and_l],
name = 'model'
)
which is has the same idea as the driver(all of these are defined models, or ETLs or other model collections)
and it creates dag like the attached image.
you already can see some of the problems with this approach, like in fit it always uses the whole data, it depends on dataframe, and it executes the code in the same place as it creates the dag...which Hamilton has a better solution for them.
I think my library should sit between user defining their models and hamilton creating the dags to make it.
after finding this library I thought I can recreate this solution with a better backend, but could not solve the problems with classes or having two modes of executionElijah Ben Izzy
01/18/2025, 10:29 PMElijah Ben Izzy
01/19/2025, 6:04 AMElijah Ben Izzy
01/20/2025, 4:31 AMdef trained_model(training_data: pd.DataFrame) -> Model:
raise NotImplementedError("TODO -- implement me! transform any other data you want with Hamilton, declare it as a fn. parameter have fun....")
def predictions(live_data: pd.DataFrame, trained_model: Model) -> pd.DataFrame:
... # ditto
And this is the high-level. The point is that users can inject the right data in, adding transforms/changing declarations. As long as they have trained_model (fit) and predictions, you’re good to go. Note you can add @config.when for training/inference. An implementation could look like this:
def trained_model(training_data: pd.DataFrame) -> Model:
X = pd.concat([data['user'], data['price'], data['competition']], axis=1)
model = Model(...)
model.fit(X, now_dict['purchase'].purchase)
return model
def predictions(self, trained_model: Model, user: pd.Series, price: pd.Series, competition: pd.Series) -> dict[str, pd.DataFrame]:
X = pd.concat([user, price, competition], axis=1)
return {'purchase': pd.DataFrame(trained_model.predict_proba(X)[:, 1], columns=['purchase'], index=X.index)}Elijah Ben Izzy
01/20/2025, 4:31 AMElijah Ben Izzy
01/20/2025, 4:34 AM@config.when()
@config.when(mode="training")
def trained_model__training(...):
...
@config.when(mode="inference")
def trained_model__inference(...):
... # maybe this loads up from your model registry?
The idea behind templates is that it’s looser than a class — the interface is just the data outputs, but there’s more you can add (and gets away with changing inputs, which interfaces don’t generally allow for.Elijah Ben Izzy
01/20/2025, 4:35 AMHadi Gharibi
01/20/2025, 10:10 AMElijah Ben Izzy
01/20/2025, 5:07 PM