-
Notifications
You must be signed in to change notification settings - 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
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d3abeb3
Standard Hawkes Process implemented
JoseKling 51088ed
- Added missing unit tests
JoseKling 71bb1d5
- Added missing unit tests
JoseKling 4be2ba4
- `HawkesProcess` is now parametric and has checks
JoseKling 245294d
- `HawkesProcess` is now parametric and has checks
JoseKling f5ac1db
- `HawkesProcess` is now parametric and has checks
JoseKling 99e6672
- Fixed type instability in `fit`
JoseKling 4ac7659
- Fixed estimation test for Hawkes processes.
JoseKling 9cb7a4d
Stable Hawkes estimation. Changed step_tol
JoseKling b0793df
Test for Hawkes process estimation based on simulated events
JoseKling ee4a88c
Remove double loop causing O(n^2) time complexity to a single pass fo…
rsenne File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
jucheval marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.