---
title: "classes + <br/> custom transformers"
subtitle: "Lecture 17"
author: "Dr. Colin Rundel"
footer: "Sta 663 - Spring 2023"
format:
  revealjs:
    theme: slides.scss
    transition: fade
    slide-number: true
    self-contained: true
execute: 
  echo: true
---

```{python setup}
#| include: false
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

import sklearn
sklearn.set_config(display="text")

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.model_selection import GridSearchCV, KFold, StratifiedKFold, train_test_split
from sklearn.metrics import classification_report

plt.rcParams['figure.dpi'] = 200

np.set_printoptions(
  edgeitems=30, linewidth=80,
  precision = 5, suppress=True
  #formatter=dict(float=lambda x: "%.5g" % x)
)

pd.set_option("display.width", 1000)
pd.set_option("display.max_columns", 10)
pd.set_option("display.precision", 6)
```

```{r r_setup}
#| include: false
knitr::opts_chunk$set(
  fig.align="center",
  cache=FALSE
)

local({
  hook_err_old <- knitr::knit_hooks$get("error")  # save the old hook
  knitr::knit_hooks$set(error = function(x, options) {
    # now do whatever you want to do with x, and pass
    # the new x to the old hook
    x = sub("## \n## Detailed traceback:\n.*$", "", x)
    x = sub("Error in py_call_impl\\(.*?\\)\\: ", "", x)
    hook_err_old(x, options)
  })
  
  hook_warn_old <- knitr::knit_hooks$get("warning")  # save the old hook
  knitr::knit_hooks$set(warning = function(x, options) {
    x = sub("<string>:1: ", "", x)
    hook_warn_old(x, options)
  })
})
```

# Classes

## Basic syntax

These are the basic component of Python's object oriented system - we've been using them regularly all over the place and will now look at how they are defined and used.


:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  """An object representation of a rectangle"""
  
  # Attributes
  p1 = (0,0)
  p2 = (1,2)
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
           
  def set_p1(self, p1):
    self.p1 = p1
  
  def set_p2(self, p2):
    self.p2 = p2
```
:::

::: {.column width='50%' .fragment}
```{python}
x = rect()
x.area()

x.set_p2((1,1))
x.area()

x.p1
x.p2

x.p2 = (0,0)
x.area()
```
:::
::::


## Instantiation (constructors)

When instantiating a class object (e.g. `rect()`) we invoke the `__init__()` method if it is present in the classes' definition.

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  """An object representation of a rectangle"""
  
  # Constructor
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
           
  def set_p1(self, p1):
    self.p1 = p1
  
  def set_p2(self, p2):
    self.p2 = p2
```
:::

::: {.column width='50%' .fragment}
```{python}
x = rect()
x.area()

y = rect((0,0), (3,3))
y.area()

z = rect((-1,-1))
z.p1
z.p2
```
:::
::::


## Method chaining

We've seen a number of objects (i.e. Pandas DataFrames) that allow for method chaining to construct a pipeline of operations. We can achieve the same by having our class methods return itself via `self`.

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  """An object representation of a rectangle"""
  
  # Constructor
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
           
  def set_p1(self, p1):
    self.p1 = p1
    return self
  
  def set_p2(self, p2):
    self.p2 = p2
    return self
```
:::

::: {.column width='50%' .fragment}
```{python}
rect().area()

rect().set_p1((-1,-1)).area()

rect().set_p1((-1,-1)).set_p2((2,2)).area()
```
:::
::::


## Class object string formating

All class objects have a default print method / string conversion method, but the default behavior is not very useful,

:::: {.columns .small}
::: {.column width='50%'}
```{python}
print(rect())
```
:::

::: {.column width='50%'}
```{python}
str(rect())
```
:::
::::

. . .

Both of the above are handled by the `__str__()` method which is implicitly created for our class - we can override this,

::: {.small}
```{python}
def rect_str(self):
  return f"Rect[{self.p1}, {self.p2}] => area={self.area()}"

rect.__str__ = rect_str
```
:::

. . .

::: {.small}
```{python}
rect()
print(rect())
str(rect())
```
:::

## Class representation

There is another special method which is responsible for the printing of the object (see `rect()` above) called `__repr__()` which is responsible for printing the classes representation. If possible this is meant to be a valid Python expression capable of recreating the object.

::: {.small}
```{python}
def rect_repr(self):
  return f"rect({self.p1}, {self.p2})"

rect.__repr__ = rect_repr
```
:::

. . .

::: {.small}
```{python}
rect()
repr(rect())
```
:::


## Inheritance

Part of the object oriented system is that classes can inherit from other classes, meaning they gain access to all of their parents attributes and methods. We will not go too in depth on this topic beyond showing the basic functionality.

::: {.small}
```{python}
class square(rect):
    pass
```
:::

. . .

::: {.small}
```{python}
square()
square().area()
square().set_p1((-1,-1)).area()
```
:::


## Overriding methods

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class square(rect):
    def __init__(self, p1=(0,0), l=1):
      assert isinstance(l, (float, int)), \
             "l must be a numnber"
      
      p2 = (p1[0]+l, p1[1]+l)
      
      self.l  = l
      super().__init__(p1, p2)
    
    def set_p1(self, p1):
      self.p1 = p1
      self.p2 = (self.p1[0]+self.l, self.p1[1]+self.l)
      return self
    
    def set_p2(self, p2):
      raise RuntimeError("Squares take l not p2")
    
    def set_l(self, l):
      assert isinstance(l, (float, int)), \
             "l must be a numnber"
      
      self.l  = l
      self.p2 = (self.p1[0]+l, self.p1[1]+l)
      return self
    
    def __repr__(self):
      return f"square({self.p1}, {self.l})"
```
:::

::: {.column width='50%' .fragment}
```{python error=TRUE}
square()
square().area()
square().set_p1((-1,-1)).area()
square().set_l(2).area()

square((0,0), (1,1))
square().set_l((0,0))
square().set_p2((0,0))
```
:::
::::


## Making an object iterable

When using an object with a for loop, python looks for the `__iter__()` method which is expected to return an iterator object (e.g. `iter()` of a list, tuple, etc.).

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  """An object representation of a rectangle"""
  
  # Constructor
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
  
  def __iter__(self):
    return iter( [
      self.p1,
      (self.p1[0], self.p2[1]),
      self.p2,
      (self.p2[0], self.p1[1])
    ] )
```
:::

::: {.column width='50%' .fragment}
```{python}
for pt in rect():
  print(pt)
```
:::
::::


## Fancier iteration

::: {.small}
A class itself can be made iterable by adding a `__next__()` method which is called until a `StopIteration` exception is encountered. In which case, `__iter__()` is still needed but should just `return self`.
:::

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
    self.vertices = [self.p1, (self.p1[0], self.p2[1]),
                     self.p2, (self.p2[0], self.p1[1]) ]
    self.index = 0
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
  
  def __iter__(self):
    return self
  
  def __next__(self):
    if self.index == len(self.vertices):
      self.index = 0
      raise StopIteration
    
    v = self.vertices[self.index]
    self.index += 1
    return v
```
:::

::: {.column width='50%' .fragment}
```{python}
r = rect()
for pt in r:
  print(pt)
  
for pt in r:
  print(pt)
```
:::
::::


## Generators

::: {.small}
There is a lot of bookkeeping in the implementation above - we can simplify this significantly by using a generator function with `__iter__()`. A generator is a function which uses `yield` instead of `return` which allows the function to preserve state between `next()` calls.
:::

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  """An object representation of a rectangle"""
  
  # Constructor
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
  
  def __iter__(self):
    vertices = [ self.p1, (self.p1[0], self.p2[1]),
                 self.p2, (self.p2[0], self.p1[1]) ]
    
    for v in vertices:
      yield v
```
:::

::: {.column width='50%' .fragment}
```{python}
r = rect()

for pt in r:
  print(pt)
  
for pt in r:
  print(pt)
```
:::
::::


## Class attributes

We can examine all of a classes' methods and attributes using `dir()`,

```{python}
np.array(
  dir(rect)
)
```

. . .

<br/>

::: {.center}
Where did `p1` and `p2` go?
:::

##

```{python}
np.array(
  dir(rect())
)
```

# Custom sklearn transformers

## FunctionTransformer

The simplest way to create a new transformer is to use `FunctionTransformer()` from the preprocessing submodule which allows for converting a Python function into a transformer.

```{python}
from sklearn.preprocessing import FunctionTransformer

X = pd.DataFrame({"x1": range(1,6), "x2": range(5, 0, -1)})
```

:::: {.columns .small}
::: {.column width='50%'}
```{python}
log_transform = FunctionTransformer(np.log)
lt = log_transform.fit(X)
lt.transform(X)

lt
```
:::

::: {.column width='50%'}
```{python}
lt.get_params()
dir(lt)
```
:::
::::


## Input types

::: {.small}
```{python, error=TRUE}
def interact(X, y = None):
  return np.c_[X, X[:,0] * X[:,1]]

X = pd.DataFrame({"x1": range(1,6), "x2": range(5, 0, -1)})
Z = np.array(X)
```
:::

. . .

:::: {.columns .small}
::: {.column width='50%'}
```{python, error=TRUE}
FunctionTransformer(interact).fit_transform(X)
```
:::

::: {.column width='50%'}
```{python, error=TRUE}
FunctionTransformer(interact).fit_transform(Z)
```
:::
::::

. . .

:::: {.columns .small}
::: {.column width='50%'}
```{python, error=TRUE}
FunctionTransformer(
  interact, validate=True
).fit_transform(X)
```
:::

::: {.column width='50%'}
```{python, error=TRUE}
FunctionTransformer(
  interact, validate=True
).fit_transform(Z)
```
:::
::::

::: {.aside}
The `validate` argument both checks that `X` is 2d as well as converts it to a np.array
:::


## Build your own transformer

For a more full featured transformer, it is possible to construct it as a class that inherits from `BaseEstimator` and `TransformerMixin` classes from the `base` submodule.

:::: {.columns .small}
::: {.column width='50%'}
```{python}
from sklearn.base import BaseEstimator, TransformerMixin

class scaler(BaseEstimator, TransformerMixin):
  def __init__(self, m = 1, b = 0):
    self.m = m
    self.b = b
  
  def fit(self, X, y=None):
    return self
  
  def transform(self, X, y=None):
    return X*self.m + self.b
```

```{python}
X = pd.DataFrame({"x1": range(1,6), "x2": range(5, 0, -1)}); X
```
:::

::: {.column width='50%' .fragment}
```{python}
double = scaler(2)
double.fit_transform(X)

double.get_params()

double.set_params(b=-3).fit_transform(X)
```
:::
::::


## What else do we get?

```{python}
#| include: false
np.set_printoptions(
  linewidth=80
)
```

```{python}
print(
  np.array(dir(double))
)
```

# Demo - Interaction Transformer


## Useful methods

We employed a couple of special methods that are worth mentioning in a little more detail.

* `_validate_data()` & `_check_feature_names()` are methods that are inherited from `BaseEstimator` they are responsible for setting and checking the `n_features_in_` and the `feature_names_in_` attributes respectively.

* In general one or both is run during `fit()` with `reset=True` in which case the respective attribute will be set.

* Later, in `tranform()` one or both will again be called with `reset=False` and the properties of `X` will be checked against the values in the attribute.

* These are worth using as they promote an interface consistent with sklearn and also provide convenient error checking with useful warning / error messages.

::: {.aside}
These methods are part of `BaseEstimator` but are not available as part of the published documentation see the code on [GitHub](https://github.com/scikit-learn/scikit-learn/blob/30bf6f39a7126a351db8971d24aa865fa5605569/sklearn/base.py#L125) or use `?sklearn.base.BaseEstimator._validate_data`
:::


## `check_is_fitted()`

This is another useful helper function from `sklearn.utils` - it is fairly simplistic in that it checks for the existence of a specified attribute. If no attribute is given then it checks for any attributes ending in `_` that do not begin with `__`.

Again this is useful for providing a consistent interface and useful error / warning messages.

See also the other `check*()` functions in `sklearn.utils`.


## Other custom estimators

If you want to implement your own custom modeling function it is possible, there are different Mixin base classes in `sklearn.base` that provide the common core interface.

| Class                       | Description                               |
|-----------------------------|-------------------------------------------|
| `base.BiclusterMixin`       | Mixin class for all bicluster estimators  |
| `base.ClassifierMixin`      | Mixin class for all classifiers           |
| `base.ClusterMixin`         | Mixin class for all cluster estimators    |
| `base.DensityMixin`         | Mixin class for all density estimators    |
| `base.RegressorMixin`       | Mixin class for all regression estimators |
| `base.TransformerMixin`     | Mixin class for all transformers          |
| `base.OneToOneFeatureMixin` | Provides get_feature_names_out for simple transformers |
