Skip to content
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
14 changes: 14 additions & 0 deletions brainiak/eventseg/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2016 Princeton University
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Event segmentation of continuous data + event transfer between datasets."""
369 changes: 369 additions & 0 deletions brainiak/eventseg/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
# Copyright 2016 Princeton University
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Event segmentation using a Hidden Markov Model

Given an ROI timeseries, this class uses an annealed fitting procedure to
segment the timeseries into events with stable activity patterns. After
learning the signature activity pattern of each event, the model can then be
applied to other datasets to identify a corresponding sequence of events.
"""

# Authors: Chris Baldassano and Cătălin Iordan (Princeton University)

import numpy as np
from scipy import stats
import logging
import copy
from sklearn.base import BaseEstimator
from sklearn.utils.validation import check_is_fitted, check_array

logger = logging.getLogger(__name__)

__all__ = [
"EventSegment",
]


class EventSegment(BaseEstimator):
"""Class for event segmentation of continuous fMRI data

Parameters
----------
n_events: int
Number of segments to learn

step_var: Callable[[int], float] : default 4 * (0.98 ** (step - 1))
The Gaussian variance to use during fitting, as a function of the
number of steps. Should decrease slowly over time.

n_iter: int : default 500
Maximum number of steps to run during fitting

Attributes
----------
p_start, p_end: length n_events+1 ndarray
initial and final prior distributions over events

P: n_events+1 by n_events+1 ndarray
HMM transition matrix

ll_ : ndarray with length = number of training datasets
Log-likelihood for training datasets over the course of training

segments_: list of (time by event) ndarrays
Learned (soft) segmentation for training datasets

event_var_ : float
Gaussian variance at the end of learning

event_pat_ : voxel by event ndarray
Learned mean patterns for each event
"""

def _default_var_schedule(step):
return 4 * (0.98 ** (step - 1))

def __init__(self, n_events=2,
step_var=_default_var_schedule,
n_iter=500):
self.n_events = n_events
self.step_var = step_var
self.n_iter = n_iter

def fit(self, X, y=None):
"""Learn a segmentation on training data

Fits event patterns and a segmentation to training data. After
running this function, the learned event patterns can be used to
segment other datasets using find_events

Parameters
----------
X: time by voxel ndarray, or a list of such ndarrays
fMRI data to be segmented. If a list is given, then all datasets
are segmented simultaneously with the same event patterns

y: not used (added to comply with BaseEstimator definition)

Returns
-------
self: the EventSegment object
"""

X = copy.deepcopy(X)
if type(X) is not list:
X = check_array(X)
X = [X]

n_train = len(X)
for i in range(n_train):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this can be avoided, e.g., by making copy of X and transpose the copy?
Because X is passed as reference, this line and line 118 which modify X will modify the data X outside the scope of this function, which might be unexpected by the user. For example, if a user wants to fit the data for multiple times, each time trying a different choice of K, (s)he might find the data has been modified after each time of fitting.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good point - I'll only modify copies.

X[i] = X[i].T

n_dim = X[0].shape[0]
for i in range(n_train):
assert (X[i].shape[0] == n_dim)

# Double-check that data is z-scored in time
for i in range(n_train):
X[i] = stats.zscore(X[i], axis=1, ddof=1)

# Set up transition matrix, with final sink state
# For transition matrix of this form, the transition probability has
# no impact on the final solution, since all valid paths must take
# the same number of transitions
self.p_start = np.zeros((1, self.n_events + 1))
self.p_start[0, 0] = 1
self.P = np.vstack((np.hstack((
0.5 * np.diag(np.ones(self.n_events))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no complaint in this comment. But I am curious whether the choice of transition probability in self.P influences the result.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprisingly, it actually doesn't - every valid path must go through the same number of transitions (since all states must be visited and none can be revisited) so they will all get hit with the same transition penalty, no matter what it is. I'll add a comment clarifying this.

+ 0.5 * np.diag(np.ones(self.n_events - 1), 1),
np.append(np.zeros((self.n_events - 1, 1)), [[0.5]], axis=0))),
np.append(np.zeros((1, self.n_events)), [[1]], axis=1)))
self.p_end = np.zeros((1, self.n_events + 1))
self.p_end[0, -2] = 1

self.classes_ = np.arange(self.n_events)

# Initialize variables for fitting
log_gamma = []
for i in range(n_train):
log_gamma.append(np.zeros((X[i].shape[1],
self.n_events)))
step = 1
best_ll = float("-inf")
self.ll_ = np.empty((0, n_train))
while step <= self.n_iter:
iteration_var = self.step_var(step)

# Based on the current segmentation, compute the mean pattern
# for each event
seg_prob = [np.exp(lg) / np.sum(np.exp(lg), axis=0)
for lg in log_gamma]
mean_pat = np.empty((n_train, n_dim, self.n_events))
for i in range(n_train):
mean_pat[i, :, :] = X[i].dot(seg_prob[i])
mean_pat = np.mean(mean_pat, axis=0)

# Based on the current mean patterns, compute the event
# segmentation
self.ll_ = np.append(self.ll_, np.empty((1, n_train)), axis=0)
for i in range(n_train):
logprob = self._logprob_obs(X[i],
mean_pat, iteration_var)
log_gamma[i], self.ll_[-1, i] = self._forward_backward(logprob)

# If log-likelihood has started decreasing, undo last step and stop
if np.mean(self.ll_[-1, :]) < best_ll:
self.ll_ = self.ll_[:-1, :]
break

self.segments_ = [np.exp(lg) for lg in log_gamma]
self.event_var_ = iteration_var
self.event_pat_ = mean_pat
best_ll = np.mean(self.ll_[-1, :])
logger.debug("Fitting step %d, LL=%f", step, best_ll)

step += 1

return self

def _logprob_obs(self, data, mean_pat, var):
"""Log probability of observing each timepoint under each event model

Computes the log probability of each observed timepoint being
generated by the Gaussian distribution for each event pattern

Parameters
----------
data: voxel by time ndarray
fMRI data on which to compute log probabilities

mean_pat: voxel by event ndarray
Centers of the Gaussians for each event

var: float or 1D array of length equal to the number of events
Variance of the event Gaussians. If scalar, all events are
assumed to have the same variance

Returns
-------
logprob : time by event ndarray
Log probability of each timepoint under each event Gaussian
"""

n_vox = data.shape[0]
t = data.shape[1]

# z-score both data and mean patterns in space, so that Gaussians
# are measuring Pearson correlations and are insensitive to overall
# activity changes
data_z = stats.zscore(data, axis=0, ddof=1)
mean_pat_z = stats.zscore(mean_pat, axis=0, ddof=1)

logprob = np.empty((t, self.n_events))

if type(var) is not np.ndarray:
var = var * np.ones(self.n_events)

for k in range(self.n_events):
logprob[:, k] = -0.5 * n_vox * np.log(
2 * np.pi * var[k]) - 0.5 * np.sum(
(data_z.T - mean_pat_z[:, k]).T ** 2, axis=0) / var[k]

logprob /= n_vox
return logprob

def _forward_backward(self, logprob):
"""Runs forward-backward algorithm on observation log probs

Given the log probability of each timepoint being generated by
each event, run the HMM forward-backward algorithm to find the
probability that each timepoint belongs to each event (based on the
transition priors in p_start, p_end, and P)

See https://en.wikipedia.org/wiki/Forward-backward_algorithm for
mathematical details

Parameters
----------
logprob : time by event ndarray
Log probability of each timepoint under each event Gaussian

Returns
-------
log_gamma : time by event ndarray
Log probability of each timepoint belonging to each event

ll : float
Log-likelihood of fit
"""
logprob = copy.copy(logprob)
t = logprob.shape[0]
logprob = np.hstack((logprob, float("-inf") * np.ones((t, 1))))

# Initialize variables
log_scale = np.zeros(t)
log_alpha = np.zeros((t, self.n_events + 1))
log_beta = np.zeros((t, self.n_events + 1))

# Forward pass
for t in range(t):
if t == 0:
log_alpha[0, :] = self._log(self.p_start) + logprob[0, :]
else:
log_alpha[t, :] = self._log(np.exp(log_alpha[t - 1, :])
.dot(self.P)) + logprob[t, :]

log_scale[t] = np.logaddexp.reduce(log_alpha[t, :])
log_alpha[t] -= log_scale[t]

# Backward pass
log_beta[-1, :] = self._log(self.p_end) - log_scale[-1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log_scale was calculated based on log_alpha in line 255, but not recalculated here based on log_beta, but it . I wonder if you need to do something similar as in line 255-256 but for log_beta. But since line 270 directly uses the log_scale calculated in line 255, perhaps you actually did not want to include anything from the backward pass in the ll? Sorry I do not know much of the forward-backward algorithm.

@cbaldassano cbaldassano Aug 26, 2016 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually correct, though I agree it looks asymmetrical. I'll add a link to the wikipedia page which explains what's going on with the log_scale parameter (called c_t on wiki) https://en.wikipedia.org/wiki/Forward%E2%80%93backward_algorithm

for t in reversed(range(t - 1)):
obs_weighted = log_beta[t + 1, :] + logprob[t + 1, :]
offset = np.max(obs_weighted)
log_beta[t, :] = offset + self._log(
np.exp(obs_weighted - offset).dot(self.P.T)) - log_scale[t]

# Combine and normalize
log_gamma = log_alpha + log_beta
log_gamma -= np.logaddexp.reduce(log_gamma, axis=1, keepdims=True)

ll = np.sum(log_scale[:(t - 1)]) + np.logaddexp.reduce(
log_alpha[-1, :] + log_scale[-1] + self._log(self.p_end), axis=1)

log_gamma = log_gamma[:, :-1]

return log_gamma, ll

def _log(self, x):
"""Modified version of np.log that manually sets values <=0 to -inf

Parameters
----------
x: ndarray of floats
Input to the log function

Returns
-------
log_ma: ndarray of floats
log of x, with x<=0 values replaced with -inf
"""
log_ma = np.ma.log(x).filled(float("-inf"))
return log_ma

def find_events(self, testing_data, var=None, scramble=False):
"""Applies learned event segmentation to new testing dataset

After fitting an event segmentation using fit(),
this function finds the same sequence of event patterns in a new
testing dataset.

Parameters
----------
testing_data: timepoint by voxel ndarray
fMRI data to segment based on previously-learned event patterns

var: float or 1D ndarray of length equal to the number of events
default: uses variance that maximized training log-likelihood
Variance of the event Gaussians. If scalar, all events are
assumed to have the same variance.

scramble: bool : default False
If true, the order of the learned events are shuffled before
fitting, to give a null distribution

Returns
-------
segments : time by event ndarray
The resulting soft segmentation. segments[t,e] = probability
that timepoint t is in event e

test_ll : float
Log-likelihood of model fit
"""

if var is None:
var = self.event_var_

if scramble:
mean_pat = self.event_pat_[:, np.random.permutation(self.n_events)]
else:
mean_pat = self.event_pat_

logprob = self._logprob_obs(testing_data.T, mean_pat, var)
lg, test_ll = self._forward_backward(logprob)
segments = np.exp(lg)

return segments, test_ll

def predict(self, X):
"""Applies learned event segmentation to new testing dataset

Alternative function for segmenting a new dataset after using
fit() to learn a sequence of events, to comply with the sklearn
Classifier interface

Parameters
----------
X: timepoint by voxel ndarray
fMRI data to segment based on previously-learned event patterns

Returns
-------
Event label for each timepoint
"""
check_is_fitted(self, ["event_pat_", "event_var_"])
X = check_array(X)
segments, test_ll = self.find_events(X)
return np.argmax(segments, axis=1)
1 change: 1 addition & 0 deletions examples/eventseg/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
matplotlib
Loading