-
Couldn't load subscription status.
- Fork 2
Standard Hawkes Process #59
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
base: main
Are you sure you want to change the base?
Changes from all commits
d3abeb3
51088ed
71bb1d5
4be2ba4
245294d
f5ac1db
99e6672
4ac7659
9cb7a4d
b0793df
ee4a88c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| """ | ||
| HawkesProcess{T<:Real} | ||
|
|
||
| Univariate Hawkes process with exponential decay kernel. | ||
|
|
||
| A Hawkes process is a self-exciting point process where each event increases the probability | ||
| of future events. The conditional intensity function is given by: | ||
|
|
||
| λ(t) = μ + α ∑_{tᵢ < t} exp(-ω(t - tᵢ)) | ||
|
|
||
| where the sum is over all previous event times tᵢ. | ||
|
|
||
| # Fields | ||
|
|
||
| - `μ::T`: baseline intensity (immigration rate) | ||
| - `α::T`: jump size (immediate increase in intensity after an event) | ||
| - `ω::T`: decay rate (how quickly the excitement fades) | ||
|
|
||
| Conditions: | ||
| - μ, α, ω >= 0 | ||
| - ψ = α/ω < 1 → Stability condition. ψ is the expected number of events each event generates | ||
|
|
||
| Following the notation from [E. Lewis, G. Mohler (2011)](https://arxiv.org/pdf/1801.08273). | ||
| """ | ||
| struct HawkesProcess{T<:Real} <: AbstractPointProcess | ||
| μ::T | ||
| α::T | ||
| ω::T | ||
|
|
||
| function HawkesProcess(μ::T1, α::T2, ω::T3) where {T1,T2,T3} | ||
| any((μ, α, ω) .< 0) && | ||
| throw(DomainError((μ, α, ω), "All parameters must be non-negative.")) | ||
| (α > 0 && α >= ω) && | ||
| throw(DomainError((α, ω), "Parameter ω must be strictly smaller than α")) | ||
| T = promote_type(T1, T2, T3) | ||
| (μ_T, α_T, ω_T) = convert.(T, (μ, α, ω)) | ||
| new{T}(μ_T, α_T, ω_T) | ||
| end | ||
| end | ||
jucheval marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| function Base.rand(rng::AbstractRNG, hp::HawkesProcess, tmin, tmax) | ||
| sim = simulate_poisson_times(rng, hp.μ, tmin, tmax) # Simulate Poisson process with base rate | ||
| sim_desc = generate_descendants(rng, sim, tmax, hp.α, hp.ω) # Recursively generates descendants from first events | ||
| append!(sim, sim_desc) | ||
| sort!(sim) | ||
jucheval marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return History(sim, fill(nothing, length(sim)), tmin, tmax) | ||
| end | ||
|
|
||
| """ | ||
| StatsAPI.fit(rng, ::Type{HawkesProcess{T}}, h::History; step_tol::Float64 = 1e-6, max_iter::Int = 1000) where {T<:Real} | ||
|
|
||
| Expectation-Maximization algorithm from [E. Lewis, G. Mohler (2011)](https://arxiv.org/pdf/1801.08273)). | ||
| The relevant calculations are in page 4, equations 6-13. | ||
JoseKling marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| Let (t₁ < ... < tₙ) be the event times over the interval [0, T). We use the immigrant-descendant representation, | ||
| where immigrants arrive at a constant base rate μ and each each arrival may generate descendants following the | ||
| activation function α exp(-ω(t - tᵢ)). | ||
|
|
||
| The algorithm consists in the following steps: | ||
| 1. Start with some initial guess for the parameters μ, ψ, and ω. ψ = α ω is the branching factor. | ||
| 2. Calculate λ(tᵢ; μ, ψ, ω) (`lambda_ts` in the code) using the procedure in [Ozaki (1979)](https://doi.org/10.1007/bf02480272). | ||
| 3. For each tᵢ and each j < i, calculate Dᵢⱼ = P(tᵢ is a descendant of tⱼ) as | ||
|
|
||
| Dᵢⱼ = ψ ω exp(-ω(tᵢ - tⱼ)) / λ(tᵢ; μ, ψ, ω). | ||
|
|
||
| Define D = ∑_{j < i} Dᵢⱼ (expected number of descendants) and div = ∑_{j < i} (tᵢ - tⱼ) Dᵢⱼ. | ||
| 4. Update the parameters as | ||
| μ = (N - D) / T | ||
| ψ = D / N | ||
| ω = D / div | ||
| 5. If convergence criterion is met, return updated parameters, otherwise, back to step 2. | ||
|
|
||
| Notice that, in the implementation, the process is normalized so the average inter-event time is equal to 1 and, | ||
| therefore, the interval of the process is transformed from T to N. Also, in equation (8) in the paper, | ||
|
|
||
| ∑_{i=1:n} pᵢᵢ = ∑_{i=1:n} (1 - ∑_{j < i} Dᵢⱼ) = N - D. | ||
|
|
||
| """ | ||
| function StatsAPI.fit( | ||
| rng::AbstractRNG, | ||
| ::Type{HawkesProcess{T}}, | ||
| h::History; | ||
| step_tol::Float64=1e-6, | ||
| max_iter::Int=1000, | ||
| ) where {T<:Real} | ||
| n = nb_events(h) | ||
| n == 0 && return HawkesProcess(zero(T), zero(T), zero(T)) | ||
|
|
||
| tmax = T(duration(h)) | ||
| # Normalize times so average inter-event time is 1 (T -> n) | ||
| norm_ts = T.(h.times .* (n / tmax)) | ||
|
|
||
| # preallocate | ||
| A = zeros(T, n) # A[i] = sum_{j<i} exp(-ω (t_i - t_j)) | ||
| S = zeros(T, n) # S[i] = sum_{j<i} (t_i - t_j) exp(-ω (t_i - t_j)) | ||
| lambda_ts = similar(A) # λ_i | ||
|
|
||
| # Λ(T) ≈ n after normalization | ||
| # μ not too close to 0 or 1 so that both base and offspring contribute | ||
| μ = T(0.2) + (T(0.6) * rand(rng, T)) | ||
| ψ = one(T) - μ # ψ = α/ω (branching ratio) | ||
| t90 = T(0.5) + (T(1.5) * rand(rng, T)) # inverse of the time to decay to 10% in normalized time | ||
| ω = log(T(10)) * t90 | ||
|
|
||
| n_iters = 0 | ||
| step = step_tol + one(T) | ||
|
|
||
| # First event: A[1]=0, S[1]=0, so λ_1 = μ | ||
| lambda_ts[1] = μ | ||
|
|
||
| while (step >= step_tol) && (n_iters < max_iter) | ||
| # compute A, S, and λ | ||
| for i in 2:n | ||
| Δ = norm_ts[i] - norm_ts[i - 1] | ||
| e = exp(-ω * Δ) | ||
| Ai_1 = A[i - 1] | ||
| A[i] = e * (one(T) + Ai_1) | ||
| S[i] = e * (S[i - 1] + Δ * (one(T) + Ai_1)) | ||
| lambda_ts[i] = μ + (ψ * ω) * A[i] | ||
| end | ||
|
|
||
| # E-step aggregates | ||
| D = zero(T) # expected number of descendants | ||
| div = zero(T) # ∑ (t_i - t_j) D_{ij} | ||
| for i in 2:n | ||
| w = (ψ * ω) / lambda_ts[i] # factor common to all j<i | ||
| D += w * A[i] | ||
| div += w * S[i] | ||
| end | ||
|
|
||
| # Steps 4–5: M-step updates + convergence check | ||
| new_μ = one(T) - (D / n) | ||
| new_ψ = D / n | ||
| new_ω = D / (div + eps(T)) # small guard to avoid div=0 | ||
|
|
||
| step = max(abs(μ - new_μ), abs(ψ - new_ψ), abs(ω - new_ω)) | ||
| μ, ψ, ω = new_μ, new_ψ, new_ω | ||
| n_iters += 1 | ||
|
|
||
| # Prepare for next loop: reset λ₁ since A[1]=0 regardless of params | ||
| lambda_ts[1] = μ | ||
| end | ||
|
|
||
| n_iters >= max_iter && @warn "Maximum number of iterations reached without convergence." | ||
|
|
||
| # Unnormalize back to original time scale (T -> tmax): | ||
| # parameters in normalized space (') relate to original by μ0=μ'*(n/tmax), ω0=ω'*(n/tmax), α0=(ψ'ω')*(n/tmax) | ||
| return HawkesProcess(μ * (n / tmax), ψ * ω * (n / tmax), ω * (n / tmax)) | ||
| end | ||
|
|
||
| function StatsAPI.fit(HP::Type{HawkesProcess{T}}, h::History; kwargs...) where {T<:Real} | ||
| return fit(default_rng(), HP, h; kwargs...) | ||
| end | ||
|
|
||
| # Type parameter for `HawkesProcess` was not explicitly provided | ||
| function StatsAPI.fit(HP::Type{HawkesProcess}, h::History{M,H}; kwargs...) where {M,H<:Real} | ||
| T = promote_type(Float64, H) | ||
| return fit(default_rng(), HP{T}, h; kwargs...) | ||
| end | ||
|
|
||
| function time_change(hp::HawkesProcess, h::History{M,T}) where {M,T<:Real} | ||
| n = nb_events(h) | ||
| A = zeros(T, n + 1) # Array A in Ozaki (1979) | ||
| @inbounds for i in 2:n | ||
| A[i] = exp(-hp.ω * (h.times[i] - h.times[i - 1])) * (1 + A[i - 1]) | ||
| end | ||
| A[end] = exp(-hp.ω * (h.tmax - h.times[end])) * (1 + A[end - 1]) # Used to calculate the integral of the intensity at every event time | ||
| times = T.(hp.μ .* (h.times .- h.tmin)) # Transformation with respect to base rate | ||
| T_base = hp.μ * duration(h) # Contribution of base rate to total length of time re-scaled process | ||
| for i in eachindex(times) | ||
| times[i] += (hp.α / hp.ω) * ((i - 1) - A[i]) # Add contribution of activation functions | ||
| end | ||
| return History(times, h.marks, zero(T), T(T_base + ((hp.α / hp.ω) * (n - A[end])))) # A time re-scaled process starts at t=0 | ||
| end | ||
|
|
||
| function ground_intensity(hp::HawkesProcess, h::History, t) | ||
| activation = sum(exp.(hp.ω .* (@view h.times[1:(searchsortedfirst(h.times, t) - 1)]))) | ||
| return hp.μ + (hp.α * activation / exp(hp.ω * t)) | ||
| end | ||
|
|
||
| function integrated_ground_intensity(hp::HawkesProcess, h::History, tmin, tmax) | ||
| times = event_times(h, h.tmin, tmax) | ||
| integral = 0 | ||
| for ti in times | ||
| # Integral of activation function. 'max(tmin - ti, 0)' corrects for events that occurred | ||
| # inside or outside the interval [tmin, tmax]. | ||
| integral += (exp(-hp.ω * max(tmin - ti, 0)) - exp(-hp.ω * (tmax - ti))) | ||
| end | ||
| integral *= hp.α / hp.ω | ||
| integral += hp.μ * (tmax - tmin) # Integral of base rate | ||
| return integral | ||
| end | ||
|
|
||
| function DensityInterface.logdensityof(hp::HawkesProcess, h::History) | ||
| A = zeros(nb_events(h)) # Vector A in Ozaki (1979) | ||
| for i in 2:nb_events(h) | ||
| A[i] = exp(-hp.ω * (h.times[i] - h.times[i - 1])) * (1 + A[i - 1]) | ||
| end | ||
| return sum(log.(hp.μ .+ (hp.α .* A))) - # Value of intensity at each event | ||
| (hp.μ * duration(h)) - # Integral of base rate | ||
| ((hp.α / hp.ω) * sum(1 .- exp.(-hp.ω .* (duration(h) .- h.times)))) # Integral of each kernel | ||
| end | ||
|
|
||
| #= | ||
| Internal function for simulating Hawkes processes | ||
| The first generation, gen_0, is the `immigrants`, which is a set of event times. | ||
| For each t_g ∈ gen_n, simulate an inhomogeneous Poisson process over the interval [t_g, T] | ||
| with intensity λ(t) = α exp(-ω(t - t_g)) with the inverse method. | ||
| gen_{n+1} is the set of all events simulated from all events in gen_n. | ||
| The algorithm stops when the simulation from one generation results in no further events. | ||
| =# | ||
| function generate_descendants( | ||
| rng::AbstractRNG, immigrants::Vector{T}, tmax, α, ω | ||
| ) where {T<:Real} | ||
| descendants = T[] | ||
| next_gen = immigrants | ||
| while !isempty(next_gen) | ||
| # OPTIMIZE: Can this be improved by avoiding allocations of `curr_gen` and `next_gen`? Or does the compiler take care of that? | ||
| curr_gen = copy(next_gen) # The current generation from which we simulate the next one | ||
| next_gen = eltype(immigrants)[] # Gathers all the descendants from the current generation | ||
| for parent in curr_gen # Generate the descendants for each individual event with the inverse method | ||
| activation_integral = (α / ω) * (one(T) - exp(ω * (parent - tmax))) | ||
| sim_transf = simulate_poisson_times(rng, one(T), zero(T), activation_integral) | ||
| @. sim_transf = parent - (inv(ω) * log(one(T) - ((ω / α) * sim_transf))) # Inverse of integral of the activation function | ||
| append!(next_gen, sim_transf) | ||
| end | ||
| append!(descendants, next_gen) | ||
| end | ||
| return descendants | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that it's more common to use uppercases at the beginning of sentences in Julia documentation There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I probably hit |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # Constructor | ||
| @test HawkesProcess(1, 1, 2) isa HawkesProcess{Int} | ||
| @test HawkesProcess(1, 1, 2.0) isa HawkesProcess{Float64} | ||
| @test_throws DomainError HawkesProcess(1, 1, 1) | ||
| @test_throws DomainError HawkesProcess(-1, 1, 2) | ||
|
|
||
| hp = HawkesProcess(1, 1, 2) | ||
| h = History([1.0, 2.0, 4.0], ["a", "b", "c"], 0.0, 5.0) | ||
| h_big = History(BigFloat.([1, 2, 4]), ["a", "b", "c"], BigFloat(0), BigFloat(5)) | ||
|
|
||
| # Time change | ||
| h_transf = time_change(hp, h) | ||
| integral = | ||
| (hp.μ * duration(h)) + | ||
| (hp.α / hp.ω) * sum([1 - exp(-hp.ω * (h.tmax - ti)) for ti in h.times]) | ||
| @test isa(h_transf, typeof(h)) | ||
| @test h_transf.marks == h.marks | ||
| @test h_transf.tmin ≈ 0 | ||
| @test h_transf.tmax ≈ integral | ||
| @test h_transf.times ≈ [1, (2 + (1 - exp(-2)) / 2), 4 + (2 - exp(-2 * 3) - exp(-2 * 2)) / 2] | ||
| @test isa(time_change(hp, h_big), typeof(h_big)) | ||
|
|
||
| # Ground intensity | ||
| @test ground_intensity(hp, h, 1) == 1 | ||
| @test ground_intensity(hp, h, 2) == 1 + hp.α * exp(-hp.ω * 1) | ||
|
|
||
| # Integrated ground intensity | ||
| @test integrated_ground_intensity(hp, h, h.tmin, h.tmax) ≈ integral | ||
| @test integrated_ground_intensity(hp, h, 2, 3) ≈ | ||
| hp.μ + | ||
| ((hp.α / hp.ω) * (exp(-hp.ω) - exp(-hp.ω * 2))) + | ||
| ((hp.α / hp.ω) * (1 - exp(-hp.ω))) | ||
|
|
||
| # Rand | ||
| h_sim = rand(hp, 0.0, 10.0) | ||
| @test issorted(h_sim.times) | ||
| @test isa(h_sim, History{Nothing,Float64}) | ||
| @test isa(rand(hp, BigFloat(0), BigFloat(10)), History{Nothing,BigFloat}) | ||
|
|
||
| # Fit | ||
| Random.seed!(123) | ||
| params_true = (100.0, 100.0, 200.0) | ||
| model = HawkesProcess(params_true...) | ||
| h_sim = rand(model, 0.0, 50.0) | ||
| model_est = fit(HawkesProcess, h_sim) | ||
| params_est = (model_est.μ, model_est.α, model_est.ω) | ||
| @test isa(model_est, HawkesProcess) | ||
| @test all((params_true .* 0.9) .<= params_est .<= (params_true .* 1.1)) | ||
| @test isa(fit(HawkesProcess, h_big), HawkesProcess{BigFloat}) | ||
| @test isa(fit(HawkesProcess{Float32}, h_big), HawkesProcess{Float32}) | ||
|
|
||
| # logdensityof | ||
| @test logdensityof(hp, h) ≈ | ||
| sum(log.(hp.μ .+ (hp.α .* [0, exp(-hp.ω), exp(-hp.ω * 2) + exp(-hp.ω * 3)]))) - | ||
| integral |
Uh oh!
There was an error while loading. Please reload this page.