Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add model spec support to HMMClassifier.__init__ #258

Merged
merged 1 commit into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/source/sections/model_selection/searching.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ versions of these methods to support sequence data.
API reference
-------------

Classes
^^^^^^^
Classes/Methods
^^^^^^^^^^^^^^^

.. autosummary::

~sequentia.model_selection.param_grid
~sequentia.model_selection.GridSearchCV
~sequentia.model_selection.RandomizedSearchCV
~sequentia.model_selection.HalvingGridSearchCV
Expand Down Expand Up @@ -81,6 +82,8 @@ cross-validate a :class:`.KNNClassifier` training pipeline. ::
Definitions
^^^^^^^^^^^

.. autofunction:: sequentia.model_selection.param_grid

.. autoclass:: sequentia.model_selection.GridSearchCV
:members: __init__
:exclude-members: __new__
Expand Down
7 changes: 6 additions & 1 deletion sequentia/model_selection/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

"""Hyper-parameter search and dataset splitting utilities."""

from sequentia.model_selection._search import GridSearchCV, RandomizedSearchCV
from sequentia.model_selection._search import (
GridSearchCV,
RandomizedSearchCV,
param_grid,
)
from sequentia.model_selection._search_successive_halving import (
HalvingGridSearchCV,
HalvingRandomSearchCV,
Expand All @@ -30,4 +34,5 @@
"RandomizedSearchCV",
"HalvingGridSearchCV",
"HalvingRandomSearchCV",
"param_grid",
]
57 changes: 56 additions & 1 deletion sequentia/model_selection/_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
# License: BSD 3 clause

import time
import typing as t
from collections import defaultdict
from itertools import product

Expand All @@ -66,7 +67,61 @@

from sequentia.model_selection._validation import _fit_and_score

__all__ = ["BaseSearchCV", "GridSearchCV", "RandomizedSearchCV"]
__all__ = ["BaseSearchCV", "GridSearchCV", "RandomizedSearchCV", "param_grid"]


def param_grid(**kwargs: list[t.Any]) -> list[dict[str, t.Any]]:
"""Generates a hyper-parameter grid for a nested object.

Examples
--------
Using :func:`.param_grid` in a grid search to cross-validate over
settings for :class:`.GaussianMixtureHMM`, which is a nested model
specified in the constructor of a :class:`.HMMClassifier`. ::

from sklearn.preprocessing import Pipeline, minmax_scale

from sequenta.enums import PriorMode, CovarianceMode, TopologyMode
from sequentia.models import HMMClassifier, GaussianMixtureHMM
from sequentia.preprocessing import IndependentFunctionTransformer
from sequentia.model_selection import GridSearchCV, StratifiedKFold

GridSearchCV(
estimator=Pipeline(
[
("scale", IndependentFunctionTransformer(minmax_scale)),
("clf", HMMClassifier(variant=GaussianMixtureHMM)),
]
),
param_grid={
"clf__prior": [PriorMode.UNIFORM, PriorMode.FREQUENCY],
"clf__model_kwargs": param_grid(
n_states=[3, 5, 7],
n_components=[2, 3, 4],
covariance=[
CovarianceMode.DIAGONAL, CovarianceMode.SPHERICAL
],
topology=[
TopologyMode.LEFT_RIGHT, TopologyMode.LINEAR
],
)
},
cv=StratifiedKFold(),
)

Parameters
----------
**kwargs:
Hyper-parameter name and corresponding values.

Returns
-------
Hyper-parameter grid for a nested object.
"""
return [
dict(zip(kwargs.keys(), values))
for values in product(*kwargs.values())
]


class BaseSearchCV(_search.BaseSearchCV):
Expand Down
98 changes: 73 additions & 25 deletions sequentia/models/hmm/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from sequentia.datasets.base import SequentialDataset
from sequentia.enums import PriorMode
from sequentia.models.base import ClassifierMixin
from sequentia.models.hmm.variants.base import BaseHMM
from sequentia.models.hmm import variants


class HMMClassifier(ClassifierMixin):
Expand All @@ -35,8 +35,9 @@ class HMMClassifier(ClassifierMixin):

Examples
--------
Using a :class:`.HMMClassifier` (with :class:`.GaussianMixtureHMM`
models) to classify spoken digits. ::
Using a :class:`.HMMClassifier` with :class:`.GaussianMixtureHMM`
models for each class (all with identical settings),
to classify spoken digits. ::

import numpy as np
from sequentia.datasets import load_digits
Expand All @@ -47,7 +48,29 @@ class HMMClassifier(ClassifierMixin):

# Fetch MFCCs of spoken digits
data = load_digits()
train_data, test_data = data.split(test_size=0.2, random_state=random_state)
train_data, test_data = data.split(
test_size=0.2, random_state=random_state
)

# Create a HMMClassifier using:
# - a separate GaussianMixtureHMM for each class (with 3 states)
# - a class frequency prior
clf = HMMClassifier(
variant=GaussianMixtureHMM,
model_kwargs=dict(n_states=3, random_state=random_state)
prior='frequency',
)

# Fit the HMMs by providing observation sequences for all classes
clf.fit(train_data.X, train_data.y, lengths=train_data.lengths)

# Predict classes for the test observation sequences
y_pred = clf.predict(test_data.X, lengths=test_data.lengths)

For more complex problems, it might be necessary to specify different
hyper-parameters for each individual class HMM. This can be done by
using :func:`add_model` or :func:`add_models` to add HMM objects
after the :class:`HMMClassifier` has been initialized. ::

# Create a HMMClassifier using a class frequency prior
clf = HMMClassifier(prior='frequency')
Expand All @@ -57,37 +80,35 @@ class HMMClassifier(ClassifierMixin):
model = GaussianMixtureHMM(random_state=random_state)
clf.add_model(model, label=label)

# Fit the HMMs by providing training observation sequences for all classes
# Fit the HMMs by providing observation sequences for all classes
clf.fit(train_data.X, train_data.y, lengths=train_data.lengths)

# Predict classes for the test observation sequences
y_pred = clf.predict(test_data.X, lengths=test_data.lengths)

As done in the above example, we can provide unfitted HMMs using
:func:`add_model` or :func:`add_models`, then provide training
observation sequences for all classes to :func:`fit`, which will
automatically train each HMM on the appropriate subset of data.

Alternatively, we may provide pre-fitted HMMs and call :func:`fit` with
no arguments. ::
Alternatively, we might want to pre-fit the HMMs individually,
then add these fitted HMMs to the :class:`.HMMClassifier`. In this case,
:func:`fit` on the :class:`.HMMClassifier` is called without providing any
data as arguments, since the HMMs are already fitted. ::

# Create a HMMClassifier using a class frequency prior
clf = HMMClassifier(prior='frequency')

# Manually fit each HMM on its own subset of data
# Manually fit each HMM on its own subset of data
for X_train, lengths_train, label for train_data.iter_by_class():
model = GaussianMixtureHMM(random_state=random_state)
model.fit(X_train, lengths=lengths_train)
clf.add_model(model, label=label)

# Fit the classifier
clf.fit()
""" # noqa: E501
"""

@pyd.validate_call(config=dict(arbitrary_types_allowed=True))
def __init__(
self: pyd.SkipValidation,
*,
variant: type[variants.CategoricalHMM]
| type[variants.GaussianMixtureHMM]
| None = None,
model_kwargs: dict[str, t.Any] | None = None,
prior: (
PriorMode | dict[int, pyd.confloat(ge=0, le=1)]
) = PriorMode.UNIFORM, # placeholder
Expand All @@ -100,10 +121,21 @@ def __init__(
----------
self: HMMClassifier

variant:
Variant of HMM to use for modelling each class. If not specified,
models must instead be added using the :func:`add_model` or
:func:`add_models` methods after the :class:`.HMMClassifier` has
been initialized.

model_kwargs:
If ``variant`` is specified, these parameters are used to
initialize the created HMM object(s). Note that all HMMs
will be created with identical settings.

prior:
Type of prior probability to assign to each HMM.

- If ``None``, a uniform prior will be used, making each HMM
- If ``"uniform"``, a uniform prior will be used, making each HMM
equally likely.
- If ``"frequency"``, the prior probability of each HMM is equal
to the fraction of total observation sequences that the HMM was
Expand Down Expand Up @@ -134,14 +166,22 @@ class labels provided here.
-------
HMMClassifier
"""
#: Type of HMM to use for each class.
self.variant: (
type[variants.CategoricalHMM]
| type[variants.GaussianMixtureHMM]
| None
) = variant
#: Model parameters for initializing HMMs.
self.model_kwargs: dict[str, t.Any] | None = model_kwargs
#: Type of prior probability to assign to each HMM.
self.prior: PriorMode | dict[int, pyd.confloat(ge=0, le=1)] = prior
#: Set of possible class labels.
self.classes: list[int] | None = classes
#: Maximum number of concurrently running workers.
self.n_jobs: pyd.PositiveInt | pyd.NegativeInt = n_jobs
#: HMMs constituting the :class:`.HMMClassifier`.
self.models: dict[int, BaseHMM] = {}
self.models: dict[int, variants.BaseHMM] = {}

# Allow metadata routing for lengths
if _sklearn.routing_enabled():
Expand All @@ -158,7 +198,7 @@ class labels provided here.
@pyd.validate_call(config=dict(arbitrary_types_allowed=True))
def add_model(
self: pyd.SkipValidation,
model: BaseHMM,
model: variants.BaseHMM,
/,
*,
label: int,
Expand Down Expand Up @@ -200,7 +240,7 @@ def add_model(
@pyd.validate_call(config=dict(arbitrary_types_allowed=True))
def add_models(
self: pyd.SkipValidation,
models: dict[int, BaseHMM],
models: dict[int, variants.BaseHMM],
/,
) -> pyd.SkipValidation:
"""Add HMMs to the classifier.
Expand Down Expand Up @@ -239,8 +279,9 @@ def fit(
- If fitted models were provided with :func:`add_model` or
:func:`add_models`, no arguments should be passed to :func:`fit`.
- If unfitted models were provided with :func:`add_model` or
:func:`add_models`, training data ``X``, ``y`` and ``lengths``
must be provided to :func:`fit`.
:func:`add_models`, or a ``variant`` was specified in
:func:`HMMClassifier.__init__`, training data ``X``, ``y`` and
``lengths`` must be provided to :func:`fit`.

Parameters
----------
Expand Down Expand Up @@ -291,6 +332,13 @@ def fit(
y = _validation.check_y(y, lengths=lengths, dtype=np.int8)
self.classes_ = _validation.check_classes(y, classes=self.classes)

# Initialize models based on instructor spec if provided
if self.variant:
model_kwargs = self.model_kwargs or {}
self.models = {
label: self.variant(**model_kwargs) for label in self.classes_
}

# Check that each label has a HMM (and vice versa)
if set(self.models.keys()) != set(self.classes_):
msg = (
Expand All @@ -312,7 +360,7 @@ def fit(
self.models[c].fit(X_c, lengths=lengths_c)

# Set class priors
models: t.Iterator[int, BaseHMM] = self.models.items()
models: t.Iterable[int, variants.BaseHMM] = self.models.items()
if self.prior == PriorMode.UNIFORM:
self.prior_ = {c: 1 / len(self.classes_) for c, _ in models}
elif self.prior == PriorMode.FREQUENCY:
Expand Down Expand Up @@ -464,7 +512,7 @@ def predict_scores(
-----
This method requires a trained classifier — see :func:`fit`.
"""
model: BaseHMM = next(iter(self.models.values()))
model: variants.BaseHMM = next(iter(self.models.values()))
X, lengths = _validation.check_X_lengths(
X,
lengths=lengths,
Expand Down
3 changes: 2 additions & 1 deletion sequentia/models/hmm/variants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

"""Supported hidden Markov Model variants."""

from sequentia.models.hmm.variants.base import BaseHMM
from sequentia.models.hmm.variants.categorical import CategoricalHMM
from sequentia.models.hmm.variants.gaussian_mixture import GaussianMixtureHMM

__all__ = ["CategoricalHMM", "GaussianMixtureHMM"]
__all__ = ["BaseHMM", "CategoricalHMM", "GaussianMixtureHMM"]
Loading
Loading