Gradient Boosting Class/Module using sklearn. (My first Python Module)
General: docstrings should explain the parameters.

features = df.drop('days_remaning', axis=1)
output = df['days_remaning']


You really ought to put 'days_remaning' into its own variable, declared either via a constant or read in from a configuration file, so that if the field name in your csv changes, you don't have to make the change in multiple places. Similar feeling for the file paths.

       print(f'Loading Local Model, product: {product}.')


This is not output of the program, but just logging its progress. So you should not be using print() but instead the logging module. That way you can configure where the logging goes (and if it's shown at all).

    if os.path.isfile(self.save_path):
        print(f'Loading Local Model, product: {product}.')
        self.model = joblib.load(self.save_path)
    else:
        print(f'Creating New Model, product: {product}.')
        self.model = self.create_model()


This seems odd to me. If the file already exists you load it, but if it doesn't exist you save it? This is actually a race condition since whoever is running this code doesn't know what behavior will happen, based on whether the file exists or is deleted. However I'm not sure what should be done here instead.

    params = dict(
        learning_rate=0.01,
        loss='ls',
        max_depth=4,
        max_features=0.3,
        min_samples_leaf=9,
        n_estimators=500
    )


The dict initializer syntax is more pythonic, and may save a function call.

    params = {
        learning_rate : 0.01,
        loss : 'ls',
        max_depth : 4,
        max_features : 0.3,
        min_samples_leaf : 9,
        n_estimators : 500
    }


but why are you even creating the params variable at all? Just call the function with named parameters!

def print_model_output(self, data: List[List[int]]) -&gt; None:
    for output in self.use_model(data):
        print('In about {} to {} days you will need to buy new {}.'.format(
            math.floor(output), math.ceil(output), self.product))


This doesn't make sense. self.product won't change through this loop. I suspect there shouldn't be a loop at all, as there's never more than one product / output.

def calculate_error_rates(self) -&gt; Tuple[float]:
    train_error = mean_absolute_error(
        self.y_train, self.model.predict(self.x_train))
    test_error = mean_absolute_error(
        self.y_test, self.model.predict(self.x_test))
    return train_error, test_error


This code strongly suggests that instead of two variables y_whatever and x_whatever you should instead have a 2-element tuple called whatever. Then you could get rid of some of this duplicated code.

    features_labels = np.array(['days_after_restock', 'left_content'])


Why an np.array here? What's wrong with a regular list?