Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
8 changes: 0 additions & 8 deletions .github/workflows/Format-PR.yml

This file was deleted.

31 changes: 27 additions & 4 deletions .github/workflows/Format.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
name: Formatter

on:
schedule:
- cron: '0 0 * * *'

push:
branches:
- 'main'
tags: ['*']
- main

pull_request:
branches:
- main

workflow_dispatch:

permissions:
contents: write
Expand All @@ -14,8 +21,11 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}

- uses: julia-actions/setup-julia@v3
with:
Expand All @@ -26,25 +36,38 @@ jobs:
- uses: julia-actions/cache@v3

- name: Install JuliaFormatter and format
if: github.event_name != 'pull_request'
shell: julia --color=yes {0}
run: |
using Pkg
Pkg.add("JuliaFormatter")
Pkg.add(Pkg.PackageSpec(name = "JuliaFormatter", version = "2"))
using JuliaFormatter
format(".")

- name: Check formatting on PR
if: github.event_name == 'pull_request'
shell: julia --color=yes {0}
run: |
using Pkg
Pkg.add(Pkg.PackageSpec(name = "JuliaFormatter", version = "2"))
using JuliaFormatter
@assert format(".", overwrite = false)

- name: Create Pull Request
id: cpr
if: github.event_name != 'pull_request'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ github.token }}
commit-message: Format .jl files
title: 'Automatic JuliaFormatter.jl run'
branch: auto-juliaformatter-pr
base: main
delete-branch: true
labels: formatting, automated pr, no changelog

- name: Check outputs
if: github.event_name != 'pull_request'
run: |
echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}"
echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"
echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"
89 changes: 69 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# EmissionModels
# EmissionModels.jl

[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://rsenne.github.io/EmissionModels.jl/stable/)
[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://rsenne.github.io/EmissionModels.jl/dev/)
Expand All @@ -8,40 +8,89 @@
[![JET](https://img.shields.io/badge/%F0%9F%9B%A9%EF%B8%8F_tested_with-JET.jl-233f9a)](https://github.com/aviatesk/JET.jl)
[![Code Style: Blue](https://img.shields.io/badge/code%20style-blue-4495d1.svg)](https://github.com/JuliaDiff/BlueStyle)

## What purpose does this package serve?
A Julia package providing emission models for [HiddenMarkovModels.jl](https://github.com/baggepinn/HiddenMarkovModels.jl). It supplies ready-to-use distributions that describe how observations are generated conditioned on the HMM's latent states.

HiddenMarkovModels.jl is completely *generic*. This means users can have models that emit generic julia objects. This level of expressiveness is allowed through the ability to create custom emission models (i.e., the models that describe how observvations are generated conditioned on the current latent state of an HMM). HiddenMarkovModels.jl expects emission types to implement a small set of methods.
## Quick start

```julia
Random.rand(rng::AbstractRNG, dist::EmissionModel)
DensityInterface.DensityKind(::EmissionModel) = HasDensity()
DensityInterface.logdensityof(dist::EmissionModel, obs)
StatsAPI.fit!(dist::EmissionModel, obs_seq, weight_seq)
using Pkg
Pkg.add("HiddenMarkovModels")
Pkg.add(url="https://github.com/rsenne/EmissionModels.jl")

using EmissionModels
using HiddenMarkovModels

# Create an emission model
dist = PoissonZeroInflated(5.0, 0.3)

# Sample, evaluate densities, or fit to data
x = rand(dist)
logp = logdensityof(dist, x)
fit!(dist, observations, weights)
```

## Distribution models

All types implement the `HiddenMarkovModels` emission interface (`rand`, `logdensityof`, `fit!`).

### Count data

| Type | Description |
|------|-------------|
| `PoissonZeroInflated(λ, π)` | Zero-inflated Poisson for excess zeros in count data. |

### Multivariate continuous

| Type | Description |
|------|-------------|
| `MultivariateT(μ, Σ, ν)` | Full-covariance multivariate Student's t. |
| `MultivariateTDiag(μ, σ², ν)` | Diagonal-covariance multivariate Student's t. |

### GLM emissions (observation depends on a control vector)

| Type | Description |
|------|-------------|
| `GaussianGLM(β, σ²)` | Linear regression with Gaussian noise. |
| `BernoulliGLM(β)` | Logistic regression for binary data. |
| `PoissonGLM(β)` | Log-linear regression for count data. |

GLM types support regularization via priors:

```julia
using EmissionModels: RidgePrior

β = zeros(3)
glm = GaussianGLM(β, 1.0, RidgePrior(0.5)) # L2 regularization
```

This package supplies many models that already implement those methods and are thus ready-to-use with HiddenMarkovModels.jl.
Each GLM is fit via `fit!(glm, y, w; control_seq=X)`, where `control_seq` (design matrix `X`) maps latent states to the regression covariates.

## Creating custom emission models

`HiddenMarkovModels.jl` accepts any type that implements the following interface:

```julia
Random.rand(rng::AbstractRNG, dist::MyEmission)
DensityInterface.DensityKind(::MyEmission) # return HasDensity()
DensityInterface.logdensityof(dist::MyEmission, obs)
StatsAPI.fit!(dist::MyEmission, obs_seq, weight_seq)
```

See the [documentation](https://rsenne.github.io/EmissionModels.jl/dev/) for details.

## Installation

This package is not yet registered on the Julia REPL. To add directly from GitHub (latest main):
EmissionModels.jl is not yet registered. Install from GitHub:

```julia
using Pkg
Pkg.add(url="https://github.com/rsenne/EmissionModels.jl")
```

## Contributing

Contributions are very welcome. Suggested ways to help:

- Open an issue for bugs or feature requests.
- Submit a pull request with tests when adding features or fixing bugs.
- Improve examples and documentation under `docs/`.

When contributing, please follow the repository coding style and add tests for new behavior.
Contributions are welcome. Please follow the [Julia Blue Style](https://github.com/JuliaDiff/BlueStyle) and add tests for new behavior. Pull requests and issues are appreciated.

## License

This project is licensed under the terms in the `LICENSE` file in the repository root.

---
*Thank you for using EmissionModels.jl — feedback and contributions appreciated.*
EmissionModels.jl is licensed under the terms of the `LICENSE` file.
2 changes: 2 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[deps]
DensityInterface = "b429d917-457f-4dbc-8f4c-0cc954292b1d"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
EmissionModels = "1e2dd27c-41a5-43b2-863c-3eddd0c72c67"
StatsAPI = "82ae8749-77ed-4fe6-ae5f-f523153014b0"
11 changes: 10 additions & 1 deletion docs/make.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using EmissionModels
using Documenter
using DensityInterface
using Random
using StatsAPI

DocMeta.setdocmeta!(EmissionModels, :DocTestSetup, :(using EmissionModels); recursive=true)

Expand All @@ -12,7 +15,13 @@ makedocs(;
edit_link="main",
assets=String[],
),
pages=["Home" => "index.md"],
pages=[
"Home" => "index.md",
"Distributions" => "distributions.md",
"GLM Emissions" => "glm.md",
"Priors" => "priors.md",
"Custom Emission Models" => "custom.md",
],
)

deploydocs(; repo="github.com/rsenne/EmissionModels.jl", devbranch="main")
82 changes: 82 additions & 0 deletions docs/src/custom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Custom Emission Models

To create a custom emission model that works with `HiddenMarkovModels.jl`, your type must implement the following methods.

## Required interface

### 1. Sample from the emission

```julia
Random.rand([rng::AbstractRNG,] dist::MyEmission)
Random.rand([rng::AbstractRNG,] dist::MyEmission, n::Integer) # n samples
```

The first form returns a single sample. The second form (optional) samples `n` i.i.d. draws.

### 2. Declare the density interface

```julia
DensityInterface.DensityKind(::MyEmission) = DensityInterface.HasDensity()
```

Return `HasDensity()` if `logdensityof` is implemented; otherwise return `NotAdjointDensity()`.

### 3. Evaluate log-density / log-probability-mass

```julia
DensityInterface.logdensityof(dist::MyEmission, obs)
```

This is called internally during the forward-backward algorithm.

### 4. Parameter estimation

```julia
StatsAPI.fit!(dist::MyEmission, obs_seq::AbstractVector, weight_seq::AbstractVector)
```

Optionally accept keyword arguments:

```julia
StatsAPI.fit!(dist::MyEmission, obs_seq, weight_seq; max_iter=100, tol=1e-6)
```

## Optional: Conditional emissions (with control vectors)

If your model depends on a design vector, add a `control` keyword to `logdensityof` and `rand`:

```julia
DensityInterface.logdensityof(dist::MyEmission, obs; control=nothing)
Random.rand([rng,] dist::MyEmission; control=nothing)
```

## Example: Bernoulli emission

```julia
using Random, DensityInterface, StatsAPI

mutable struct MyBernoulli{T<:Real}
p::T # probability of success
end

DensityInterface.DensityKind(::MyBernoulli) = DensityInterface.HasDensity()

function DensityInterface.logdensityof(b::MyBernoulli, obs::Integer)
obs ∈ (0, 1) || return oftype(b.p, -Inf)
return obs * log(b.p) + (one(obs) - obs) * log(one(b.p) - b.p)
end

function Random.rand(rng::AbstractRNG, b::MyBernoulli)
rand(rng) < b.p ? 1 : 0
end

# Fitting: MLE for Bernoulli is the sample mean
function StatsAPI.fit!(b::MyBernoulli, obs_seq, weight_seq)
total_w = sum(weight_seq)
b.p = dot(weight_seq, obs_seq) / total_w
return b
end

# Use in an HMM:
emission = MyBernoulli(0.5)
```
Loading