diff --git a/doc/api.rst b/doc/api.rst index e970ff0d..d34b0fe6 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -326,6 +326,7 @@ Multiple Signals sim_multiple sim_across_values + sim_multi_across_values sim_from_sampler Simulation Parameters @@ -370,13 +371,13 @@ The following objects can be used to manage groups of simulated signals: :toctree: generated/ Simulations - SampledSimulations + VariableSimulations MultiSimulations -Utilities -~~~~~~~~~ +Modulate Signals +~~~~~~~~~~~~~~~~ -.. currentmodule:: neurodsp.sim.utils +.. currentmodule:: neurodsp.sim.modulate .. autosummary:: :toctree: generated/ @@ -384,6 +385,16 @@ Utilities rotate_timeseries modulate_signal +I/O +~~~ + +.. currentmodule:: neurodsp.sim.io +.. autosummary:: + :toctree: generated/ + + save_sims + load_sims + Random Seed ~~~~~~~~~~~ diff --git a/neurodsp/filt/filter.py b/neurodsp/filt/filter.py index 74a8d343..d624af95 100644 --- a/neurodsp/filt/filter.py +++ b/neurodsp/filt/filter.py @@ -1,7 +1,5 @@ """Filter time series.""" -from warnings import warn - from neurodsp.filt.fir import filter_signal_fir from neurodsp.filt.iir import filter_signal_iir from neurodsp.utils.checks import check_param_options diff --git a/neurodsp/sim/__init__.py b/neurodsp/sim/__init__.py index c60b84d1..47f3bab3 100644 --- a/neurodsp/sim/__init__.py +++ b/neurodsp/sim/__init__.py @@ -9,4 +9,4 @@ from .cycles import sim_cycle from .transients import sim_synaptic_kernel, sim_action_potential from .combined import sim_combined, sim_peak_oscillation, sim_modulated_signal, sim_combined_peak -from .multi import sim_multiple, sim_across_values, sim_from_sampler +from .multi import sim_multiple, sim_across_values, sim_multi_across_values, sim_from_sampler diff --git a/neurodsp/sim/aperiodic.py b/neurodsp/sim/aperiodic.py index f9c0b83d..7ba4262f 100644 --- a/neurodsp/sim/aperiodic.py +++ b/neurodsp/sim/aperiodic.py @@ -7,12 +7,12 @@ from neurodsp.filt import filter_signal, infer_passtype from neurodsp.filt.fir import compute_filter_length from neurodsp.filt.checks import check_filter_definition -from neurodsp.utils import remove_nans +from neurodsp.utils.norm import normalize_sig +from neurodsp.utils.outliers import remove_nans +from neurodsp.utils.decorators import normalize from neurodsp.utils.checks import check_param_range from neurodsp.utils.data import create_times, compute_nsamples -from neurodsp.utils.decorators import normalize -from neurodsp.utils.norm import normalize_sig -from neurodsp.sim.utils import rotate_timeseries +from neurodsp.sim.modulate import rotate_timeseries from neurodsp.sim.transients import sim_synaptic_kernel ################################################################################################### diff --git a/neurodsp/sim/combined.py b/neurodsp/sim/combined.py index c7501bcb..9cee3eb3 100644 --- a/neurodsp/sim/combined.py +++ b/neurodsp/sim/combined.py @@ -6,9 +6,9 @@ from scipy.linalg import norm from neurodsp.sim.info import get_sim_func -from neurodsp.sim.utils import modulate_signal -from neurodsp.utils.decorators import normalize +from neurodsp.sim.modulate import modulate_signal from neurodsp.utils.data import create_times +from neurodsp.utils.decorators import normalize ################################################################################################### ################################################################################################### @@ -63,8 +63,7 @@ def sim_combined(n_seconds, fs, components, component_variances=1): raise ValueError('Signal components and variances lengths do not match.') # Collect the sim function to use, and repeat variance if is single number - components = {(get_sim_func(name) if isinstance(name, str) else name) : params \ - for name, params in components.items()} + components = {get_sim_func(name) : params for name, params in components.items()} variances = repeat(component_variances) if \ isinstance(component_variances, (int, float, np.number)) else iter(component_variances) diff --git a/neurodsp/sim/generators.py b/neurodsp/sim/generators.py new file mode 100644 index 00000000..b7ea4c9e --- /dev/null +++ b/neurodsp/sim/generators.py @@ -0,0 +1,77 @@ +"""Generator simulation functions.""" + +from collections.abc import Sized + +from neurodsp.sim.info import get_sim_func +from neurodsp.utils.core import counter + +################################################################################################### +################################################################################################### + +def sig_yielder(function, params, n_sims): + """Generator to yield simulated signals from a given simulation function and parameters. + + Parameters + ---------- + function : str or callable + Function to create the simulated time series. + If string, should be the name of the desired simulation function. + params : dict + The parameters for the simulated signal, passed into `function`. + n_sims : int, optional + Number of simulations to set as the max. + If None, creates an infinite generator. + + Yields + ------ + sig : 1d array + Simulated time series. + """ + + function = get_sim_func(function) + for _ in counter(n_sims): + yield function(**params) + + +def sig_sampler(function, params, return_params=False, n_sims=None): + """Generator to yield simulated signals from a parameter sampler. + + Parameters + ---------- + function : str or callable + Function to create the simulated time series. + If string, should be the name of the desired simulation function. + params : iterable + The parameters for the simulated signal, passed into `function`. + return_params : bool, optional, default: False + Whether to yield the simulation parameters as well as the simulated time series. + n_sims : int, optional + Number of simulations to set as the max. + If None, length is defined by the length of `params`, and could be infinite. + + Yields + ------ + sig : 1d array + Simulated time series. + sample_params : dict + Simulation parameters for the yielded time series. + Only returned if `return_params` is True. + """ + + function = get_sim_func(function) + + # If `params` has a size, and `n_sims` is defined, check that they are compatible + # To do so, we first check if the iterable has a __len__ attr, and if so check values + if isinstance(params, Sized) and len(params) and n_sims and n_sims > len(params): + msg = 'Cannot simulate the requested number of sims with the given parameters.' + raise ValueError(msg) + + for ind, sample_params in zip(counter(n_sims), params): + + if return_params: + yield function(**sample_params), sample_params + else: + yield function(**sample_params) + + if n_sims and ind >= n_sims: + break diff --git a/neurodsp/sim/info.py b/neurodsp/sim/info.py index 746db886..e917b341 100644 --- a/neurodsp/sim/info.py +++ b/neurodsp/sim/info.py @@ -9,39 +9,40 @@ SIM_MODULES = ['periodic', 'aperiodic', 'cycles', 'transients', 'combined'] -def get_sim_funcs(module_name): +def get_sim_funcs(module): """Get the available sim functions from a specified sub-module. Parameters ---------- - module_name : {'periodic', 'aperiodic', 'cycles', 'transients', 'combined'} + module : {'periodic', 'aperiodic', 'cycles', 'transients', 'combined'} Simulation sub-module to get sim functions from. Returns ------- - funcs : dictionary + functions : dictionary A dictionary containing the available sim functions from the requested sub-module. """ - check_param_options(module_name, 'module_name', SIM_MODULES) + check_param_options(module, 'module', SIM_MODULES) # Note: imports done within function to avoid circular import from neurodsp.sim import periodic, aperiodic, transients, combined, cycles - module = eval(module_name) + module = eval(module) - funcs = {name : func for name, func in getmembers(module, isfunction) \ - if name[0:4] == 'sim_' and func.__module__.split('.')[-1] == module.__name__.split('.')[-1]} + module_name = module.__name__.split('.')[-1] + functions = {name : function for name, function in getmembers(module, isfunction) \ + if name[0:4] == 'sim_' and function.__module__.split('.')[-1] == module_name} - return funcs + return functions -def get_sim_names(module_name): +def get_sim_names(module): """Get the names of the available sim functions from a specified sub-module. Parameters ---------- - module_name : {'periodic', 'aperiodic', 'transients', 'combined'} + module : {'periodic', 'aperiodic', 'transients', 'combined'} Simulation sub-module to get sim functions from. Returns @@ -50,28 +51,33 @@ def get_sim_names(module_name): The names of the available functions in the requested sub-module. """ - return list(get_sim_funcs(module_name).keys()) + return list(get_sim_funcs(module).keys()) -def get_sim_func(function_name, modules=SIM_MODULES): +def get_sim_func(function, modules=SIM_MODULES): """Get a specified sim function. Parameters ---------- - function_name : str + function : str or callabe Name of the sim function to retrieve. + If callable, returns input. + If string searches for corresponding callable sim function. modules : list of str, optional Which sim modules to look for the function in. Returns ------- - func : callable + function : callable Requested sim function. """ + if callable(function): + return function + for module in modules: try: - func = get_sim_funcs(module)[function_name] + function = get_sim_funcs(module)[function] break except KeyError: continue @@ -79,4 +85,23 @@ def get_sim_func(function_name, modules=SIM_MODULES): else: raise ValueError('Requested simulation function not found.') from None - return func + return function + + +def get_sim_func_name(function): + """Get the name of a simulation function. + + Parameters + ---------- + function : str or callabe + Function to get name for. + + Returns + ------- + name : str + Name of the function. + """ + + name = function.__name__ if callable(function) else function + + return name diff --git a/neurodsp/sim/io.py b/neurodsp/sim/io.py new file mode 100644 index 00000000..bc7f7be3 --- /dev/null +++ b/neurodsp/sim/io.py @@ -0,0 +1,209 @@ +"""Simulation I/O functions that return multiple instances.""" + +import os +import json +from pathlib import Path + +import numpy as np + +from neurodsp.sim.signals import Simulations, VariableSimulations, MultiSimulations + +################################################################################################### +################################################################################################### + +## BASE I/O UTILITIES + +def fpath(file_path, file_name): + """Check and combine a file name and path into a Path object. + + Parameters + ---------- + file_path : str or None + Name of the directory. + file_name : str + Name of the file. + + Returns + ------- + Path + Path object for the full file path. + """ + + return Path(file_path) / file_name if file_path else Path(file_name) + + +def save_json(file_name, data): + """Save data to a json file. + + Parameters + ---------- + file_name : str + Name of the file to save to. + data : dict + Data to save to file. + """ + + with open(file_name, 'w') as json_file: + json.dump(data, json_file) + + +def save_jsonlines(file_name, data): + """Save data to a jsonlines file. + + Parameters + ---------- + file_name : str + Name of the file to save to. + data : list of dict + Data to save to file. + """ + + with open(file_name, 'w') as jsonlines_file: + for row in data: + json.dump(row, jsonlines_file) + jsonlines_file.write('\n') + + +def load_json(file_name): + """Load data from a json file. + + Parameters + ---------- + file_name : str + Name of the file to load from. + + Returns + ------- + data : dict + Loaded data. + """ + + with open(file_name, 'r') as json_file: + data = json.load(json_file) + + return data + + +def load_jsonlines(file_name): + """Load data from a jsonlines file. + + Parameters + ---------- + file_name : str + Name of the file to load from. + + Returns + ------- + data : list of dict + Loaded data. + """ + + data = [] + with open(file_name, 'r') as json_file: + for row in json_file: + data.append(json.loads(row)) + + return data + + +## SIMULATION OBJECT I/O + +def save_sims(sims, label, file_path=None, replace=False): + """Save simulations. + + Parameters + ---------- + sims : Simulations or VariableSimulations or MultipleSimulations + Simulations to save. + label : str + Label to attach to the simulation name. + file_path : str, optional + Directory to save to. + replace : bool, optional, default: False + Whether to replace any existing saved files with the same name. + """ + + assert '_' not in label, 'Cannot have underscores in simulation label.' + + save_path_items = ['sim-unknown' if not sims.function else sims.function.replace('_', '-')] + if isinstance(sims, (VariableSimulations, MultiSimulations)): + if sims.component: + save_path_items.append(sims.component.replace('_', '-')) + if sims.update: + save_path_items.append(sims.update) + save_path_items.append(label) + save_path = '_'.join(save_path_items) + + save_folder = fpath(file_path, save_path) + + if os.path.exists(save_folder): + if not replace: + raise ValueError('Simulation files already exist.') + else: + os.mkdir(save_folder) + + if isinstance(sims, MultiSimulations): + for ind, csims in enumerate(sims.signals): + save_sims(csims, 'set' + str(ind), file_path=save_folder, replace=True) + + else: + np.save(save_folder / 'signals', sims.signals) + if isinstance(sims, VariableSimulations): + save_jsonlines(save_folder / 'params.jsonlines', sims.params) + elif isinstance(sims, Simulations): + save_json(save_folder / 'params.json', sims.params) + + +def load_sims(load_name, file_path=None): + """Load simulations. + + Parameters + ---------- + load_name : str + The name or label of the simulations to load. + If not the full file name, this string can be the label the simulations were saved with. + file_path : str, optional + Directory to load from. + + Returns + ------- + sims : Simulations or VariableSimulations or MultipleSimulations + Loaded simulations. + """ + + if '_' not in load_name: + matches = [el for el in os.listdir(file_path) if load_name in el] + assert len(matches) > 0, 'No matches found for requested simulation label.' + assert len(matches) == 1, 'Multiple matches found for requested simulation label.' + load_name = matches[0] + + splits = load_name.split('_') + function = splits[0].replace('-', '_') if 'unknown' not in splits[0] else None + + update, component = None, None + if len(splits) > 2: + splits = splits[1:-1] + update = splits.pop() + component = splits[0].replace('-', '_') if splits else None + + load_folder = fpath(file_path, load_name) + load_files = sorted([file for file in os.listdir(load_folder) if file[0] != '.']) + + if 'signals.npy' not in load_files: + + msims = [load_sims(load_file, load_folder) for load_file in load_files] + sims = MultiSimulations(msims, None, function, update, component) + + else: + + sigs = np.load(load_folder / 'signals.npy') + + if 'params.json' in load_files: + params = load_json(load_folder / 'params.json') + sims = Simulations(sigs, params, function) + + elif 'params.jsonlines' in load_files: + params = load_jsonlines(load_folder / 'params.jsonlines') + sims = VariableSimulations(sigs, params, function, update, component) + + return sims diff --git a/neurodsp/sim/modulate.py b/neurodsp/sim/modulate.py new file mode 100644 index 00000000..6517470f --- /dev/null +++ b/neurodsp/sim/modulate.py @@ -0,0 +1,165 @@ +"""Functionality to modulate simulations.""" + +import numpy as np + +from neurodsp.utils.data import compute_nseconds +from neurodsp.sim.info import get_sim_func + +################################################################################################### +################################################################################################### + +def rotate_timeseries(sig, fs, delta_exp, f_rotation=1): + """Rotate a timeseries of data, changing it's 1/f exponent. + + Parameters + ---------- + sig : 1d array + A time series to rotate. + fs : float + Sampling rate of the signal, in Hz. + delta_exp : float + Change in power law exponent to be applied. + Positive is clockwise rotation (steepen), negative is counter clockwise rotation (flatten). + f_rotation : float, optional, default: 1 + Frequency, in Hz, to rotate the spectrum around, where power is unchanged by the rotation. + + Returns + ------- + sig_rotated : 1d array + The rotated version of the signal. + + Notes + ----- + This function works by taking the FFT and spectrally rotating the input signal. + To return a timeseries, the rotated FFT is then turned back into a time series, with an iFFT. + + Examples + -------- + Rotate a timeseries of simulated data: + + >>> from neurodsp.sim import sim_combined + >>> sig = sim_combined(n_seconds=10, fs=500, + ... components={'sim_powerlaw': {}, 'sim_oscillation' : {'freq': 10}}) + >>> rotated_sig = rotate_timeseries(sig, fs=500, delta_exp=0.5) + """ + + # Compute the FFT + fft_vals = np.fft.fft(sig) + freqs = np.fft.fftfreq(len(sig), 1./fs) + + # Rotate the spectrum to create the exponent change + # Delta exponent is divided by two, as the FFT output is in units of amplitude not power + fft_rotated = rotate_spectrum(freqs, fft_vals, delta_exp/2, f_rotation) + + # Invert back to time series, with a z-score to normalize + sig_rotated = np.real(np.fft.ifft(fft_rotated)) + + return sig_rotated + + +def rotate_spectrum(freqs, spectrum, delta_exponent, f_rotation=1): + """Rotate the power law exponent of a power spectrum. + + Parameters + ---------- + freqs : 1d array + Frequency axis of input spectrum, in Hz. + spectrum : 1d array + Power spectrum to be rotated. + delta_exponent : float + Change in power law exponent to be applied. + Positive is clockwise rotation (steepen), negative is counter clockwise rotation (flatten). + f_rotation : float, optional, default: 1 + Frequency, in Hz, to rotate the spectrum around, where power is unchanged by the rotation. + This only matters if not further normalizing signal variance. + + Returns + ------- + rotated_spectrum : 1d array + Rotated spectrum. + + Notes + ----- + The input power spectrum is multiplied with a mask that applies the specified exponent change. + + Examples + -------- + Rotate a power spectrum, calculated on simulated data: + + >>> from neurodsp.sim import sim_combined + >>> from neurodsp.spectral import compute_spectrum + >>> sig = sim_combined(n_seconds=10, fs=500, + ... components={'sim_powerlaw': {}, 'sim_oscillation' : {'freq': 10}}) + >>> freqs, spectrum = compute_spectrum(sig, fs=500) + >>> rotated_spectrum = rotate_spectrum(freqs, spectrum, -1) + """ + + if freqs[0] == 0: + skipped_zero = True + f_0, p_0 = freqs[0], spectrum[0] + freqs, spectrum = freqs[1:], spectrum[1:] + else: + skipped_zero = False + + mask = (np.abs(freqs) / f_rotation)**-delta_exponent + rotated_spectrum = mask * spectrum + + if skipped_zero: + freqs = np.insert(freqs, 0, f_0) + rotated_spectrum = np.insert(rotated_spectrum, 0, p_0) + + return rotated_spectrum + + +def modulate_signal(sig, modulation, fs=None, mod_params=None): + """Apply amplitude modulation to a signal. + + Parameters + ---------- + sig : 1d array + A signal to modulate. + modulation : 1d array or str + Modulation to apply to the signal. + If array, the modulating signal to apply directly to the signal. + If str, a function name to use to simulate the modulating signal that will be applied. + fs : float, optional + Signal sampling rate, in Hz. + Only needed if `modulation` is a callable. + mod_params : dictionary, optional + Parameters for the modulation function. + Only needed if `modulation` is a callable. + + Returns + ------- + msig : 1d array + Amplitude modulated signal. + + Examples + -------- + Amplitude modulate a sinusoidal signal with a lower frequency, passing in a function label: + + >>> from neurodsp.sim import sim_oscillation + >>> fs = 500 + >>> sig = sim_oscillation(n_seconds=10, fs=fs, freq=10) + >>> msig = modulate_signal(sig, 'sim_oscillation', fs, {'freq' : 1}) + + Amplitude modulate a sinusoidal signal with precomputed 1/f signal: + + >>> from neurodsp.sim import sim_oscillation, sim_powerlaw + >>> n_seconds = 10 + >>> fs = 500 + >>> sig = sim_oscillation(n_seconds, fs, freq=10) + >>> mod = sim_powerlaw(n_seconds, fs, exponent=-1) + >>> msig = modulate_signal(sig, mod) + """ + + if isinstance(modulation, str): + mod_func = get_sim_func(modulation) + modulation = mod_func(compute_nseconds(sig, fs), fs, **mod_params) + + assert len(sig) == len(modulation), \ + 'Lengths of the signal and modulator must match to apply modulation' + + msig = sig * modulation + + return msig diff --git a/neurodsp/sim/multi.py b/neurodsp/sim/multi.py index f0020866..b9cad1f4 100644 --- a/neurodsp/sim/multi.py +++ b/neurodsp/sim/multi.py @@ -1,193 +1,156 @@ """Simulation functions that return multiple instances.""" -from collections.abc import Sized - -import numpy as np - -from neurodsp.utils.core import counter -from neurodsp.sim.signals import Simulations, SampledSimulations, MultiSimulations +from neurodsp.sim.signals import Simulations, VariableSimulations, MultiSimulations +from neurodsp.sim.generators import sig_yielder, sig_sampler +from neurodsp.sim.params import get_base_params +from neurodsp.sim.info import get_sim_func ################################################################################################### ################################################################################################### -def sig_yielder(sim_func, sim_params, n_sims): - """Generator to yield simulated signals from a given simulation function and parameters. +def sim_multiple(function, params, n_sims): + """Simulate multiple samples of a specified simulation. Parameters ---------- - sim_func : callable + function : str or callable Function to create the simulated time series. - sim_params : dict - The parameters for the simulated signal, passed into `sim_func`. - n_sims : int, optional - Number of simulations to set as the max. - If None, creates an infinite generator. - - Yields - ------ - sig : 1d array - Simulated time series. - """ - - for _ in counter(n_sims): - yield sim_func(**sim_params) + If string, should be the name of the desired simulation function. + params : dict + The parameters for the simulated signal, passed into `function`. + n_sims : int + Number of simulations to create. + Returns + ------- + sims : Simulations + Simulations object with simulated time series and metadata. + Simulated signals are in the 'signals' attribute with shape [n_sims, sig_length]. -def sig_sampler(sim_func, sim_params, return_sim_params=False, n_sims=None): - """Generator to yield simulated signals from a parameter sampler. + Examples + -------- + Simulate multiple samples of a powerlaw signal: - Parameters - ---------- - sim_func : callable - Function to create the simulated time series. - sim_params : iterable - The parameters for the simulated signal, passed into `sim_func`. - return_sim_params : bool, optional, default: False - Whether to yield the simulation parameters as well as the simulated time series. - n_sims : int, optional - Number of simulations to set as the max. - If None, length is defined by the length of `sim_params`, and could be infinite. - - Yields - ------ - sig : 1d array - Simulated time series. - sample_params : dict - Simulation parameters for the yielded time series. - Only returned if `return_sim_params` is True. + >>> from neurodsp.sim.aperiodic import sim_powerlaw + >>> params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} + >>> sims = sim_multiple(sim_powerlaw, params, n_sims=3) """ - # If `sim_params` has a size, and `n_sims` is defined, check that they are compatible - # To do so, we first check if the iterable has a __len__ attr, and if so check values - if isinstance(sim_params, Sized) and len(sim_params) and n_sims and n_sims > len(sim_params): - msg = 'Cannot simulate the requested number of sims with the given parameters.' - raise ValueError(msg) - - for ind, sample_params in zip(counter(n_sims), sim_params): + sims = Simulations(n_sims, params, function) + for ind, sig in enumerate(sig_yielder(function, params, n_sims)): + sims.add_signal(sig, index=ind) - if return_sim_params: - yield sim_func(**sample_params), sample_params - else: - yield sim_func(**sample_params) + return sims - if n_sims and ind >= n_sims: - break - -def sim_multiple(sim_func, sim_params, n_sims, return_type='object'): - """Simulate multiple samples of a specified simulation. +def sim_across_values(function, params): + """Simulate signals across different parameter values. Parameters ---------- - sim_func : callable + function : str or callable Function to create the simulated time series. - sim_params : dict - The parameters for the simulated signal, passed into `sim_func`. - n_sims : int - Number of simulations to create. - return_type : {'object', 'array'} - Specifies the return type of the simulations. - If 'object', returns simulations and metadata in a 'Simulations' object. - If 'array', returns the simulations (no metadata) in an array. + If string, should be the name of the desired simulation function. + params : ParamIter or iterable or list of dict + Simulation parameters for `function`. Returns ------- - sigs : Simulations or 2d array - Simulations, return type depends on `return_type` argument. - Simulated time series are organized as [n_sims, sig length]. + sims : VariableSimulations + Simulations object with simulated time series and metadata. + Simulated signals are in the 'signals' attribute with shape [n_sims, sig_length]. Examples -------- - Simulate multiple samples of a powerlaw signal: + Simulate multiple powerlaw signals using a ParamIter object: >>> from neurodsp.sim.aperiodic import sim_powerlaw - >>> params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} - >>> sigs = sim_multiple(sim_powerlaw, params, n_sims=3) + >>> from neurodsp.sim.update import ParamIter + >>> base_params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : None} + >>> param_iter = ParamIter(base_params, 'exponent', [-2, 1, 0]) + >>> sims = sim_across_values(sim_powerlaw, param_iter) + + Simulate multiple powerlaw signals from manually defined set of simulation parameters: + + >>> params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, + ... {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] + >>> sims = sim_across_values(sim_powerlaw, params) """ - sigs = np.zeros([n_sims, sim_params['n_seconds'] * sim_params['fs']]) - for ind, sig in enumerate(sig_yielder(sim_func, sim_params, n_sims)): - sigs[ind, :] = sig + sims = VariableSimulations(len(params), get_base_params(params), function, + update=getattr(params, 'update', None), + component=getattr(params, 'component', None)) - if return_type == 'object': - return Simulations(sigs, sim_params, sim_func) - else: - return sigs + function = get_sim_func(function) + for ind, cur_params in enumerate(params): + sims.add_signal(function(**cur_params), cur_params, index=ind) + + return sims -def sim_across_values(sim_func, sim_params, n_sims, output='object'): + +def sim_multi_across_values(function, params, n_sims): """Simulate multiple signals across different parameter values. Parameters ---------- - sim_func : callable + function : str or callable Function to create the simulated time series. - sim_params : ParamIter or iterable or list of dict - Simulation parameters for `sim_func`. + If string, should be the name of the desired simulation function. + params : ParamIter or iterable or list of dict + Simulation parameters for `function`. n_sims : int Number of simulations to create per parameter definition. - return_type : {'object', 'array'} - Specifies the return type of the simulations. - If 'object', returns simulations and metadata in a 'MultiSimulations' object. - If 'array', returns the simulations (no metadata) in an array. Returns ------- - sims : MultiSimulations or array - Simulations, return type depends on `return_type` argument. - If array, signals are collected together as [n_sets, n_sims, sig_length]. + sims : MultiSimulations + Simulations object with simulated time series and metadata. + Simulated signals are in the 'signals' attribute with shape [n_sets, n_sims, sig_length]. Examples -------- Simulate multiple powerlaw signals using a ParamIter object: >>> from neurodsp.sim.aperiodic import sim_powerlaw - >>> from neurodsp.sim.params import ParamIter + >>> from neurodsp.sim.update import ParamIter >>> base_params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : None} >>> param_iter = ParamIter(base_params, 'exponent', [-2, 1, 0]) - >>> sigs = sim_across_values(sim_powerlaw, param_iter, n_sims=2) + >>> sims = sim_multi_across_values(sim_powerlaw, param_iter, n_sims=2) Simulate multiple powerlaw signals from manually defined set of simulation parameters: >>> params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, ... {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] - >>> sigs = sim_across_values(sim_powerlaw, params, n_sims=2) + >>> sims = sim_multi_across_values(sim_powerlaw, params, n_sims=2) """ - update = sim_params.update if \ - not isinstance(sim_params, dict) and hasattr(sim_params, 'update') else None - - sims = MultiSimulations(update=update) - for ind, cur_sim_params in enumerate(sim_params): - sims.add_signals(sim_multiple(sim_func, cur_sim_params, n_sims, 'object')) - - if output == 'array': - sims = np.squeeze(np.array([el.signals for el in sims])) + sims = MultiSimulations(update=getattr(params, 'update', None), + component=getattr(params, 'component', None)) + for cur_params in params: + sims.add_signals(sim_multiple(function, cur_params, n_sims)) return sims -def sim_from_sampler(sim_func, sim_sampler, n_sims, return_type='object'): +def sim_from_sampler(function, sampler, n_sims): """Simulate a set of signals from a parameter sampler. Parameters ---------- - sim_func : callable + function : str or callable Function to create the simulated time series. - sim_sampler : ParamSampler + If string, should be the name of the desired simulation function. + sampler : ParamSampler Parameter definition to sample from. n_sims : int Number of simulations to create per parameter definition. - return_type : {'object', 'array'} - Specifies the return type of the simulations. - If 'object', returns simulations and metadata in a 'SampledSimulations' object. - If 'array', returns the simulations (no metadata) in an array. Returns ------- - sigs : SampledSimulations or 2d array - Simulations, return type depends on `return_type` argument. - If array, simulations are organized as [n_sims, sig length]. + sims : VariableSimulations + Simulations object with simulated time series and metadata. + Simulated signals are in the 'signals' attribute with shape [n_sims, sig_length]. Examples -------- @@ -198,16 +161,11 @@ def sim_from_sampler(sim_func, sim_sampler, n_sims, return_type='object'): >>> params = {'n_seconds' : 10, 'fs' : 250, 'exponent' : None} >>> samplers = {create_updater('exponent') : create_sampler([-2, -1, 0])} >>> param_sampler = ParamSampler(params, samplers) - >>> sigs = sim_from_sampler(sim_powerlaw, param_sampler, n_sims=2) + >>> sims = sim_from_sampler(sim_powerlaw, param_sampler, n_sims=2) """ - all_params = [None] * n_sims - sigs = np.zeros([n_sims, sim_sampler.params['n_seconds'] * sim_sampler.params['fs']]) - for ind, (sig, params) in enumerate(sig_sampler(sim_func, sim_sampler, True, n_sims)): - sigs[ind, :] = sig - all_params[ind] = params + sims = VariableSimulations(n_sims, get_base_params(sampler), function) + for ind, (sig, params) in enumerate(sig_sampler(function, sampler, True, n_sims)): + sims.add_signal(sig, params, index=ind) - if return_type == 'object': - return SampledSimulations(sigs, all_params, sim_func) - else: - return sigs + return sims diff --git a/neurodsp/sim/params.py b/neurodsp/sim/params.py index c46ebcfc..b25c1cd3 100644 --- a/neurodsp/sim/params.py +++ b/neurodsp/sim/params.py @@ -6,11 +6,108 @@ from copy import deepcopy -from neurodsp.sim.update import ParamIter, ParamSampler - ################################################################################################### ################################################################################################### +BASE_PARAMS = ['n_seconds', 'fs'] + +## SIMULATION PARAMETER FUNCTIONS + +def get_base_params(params): + """Get base parameters from a parameter definition. + + Parameters + ---------- + params : dict or list of dict or Object + Parameter definition. + + Returns + ------- + base : dict + Base parameters. + """ + + from neurodsp.sim.update import BaseUpdater + + if isinstance(params, dict): + base = _get_base_params(params) + elif isinstance(params, list): + base = _get_base_params(params[0]) + elif isinstance(params, BaseUpdater): + base = getattr(params, 'base') + else: + raise ValueError('Parameter definition not understood.') + + return base + + +def _get_base_params(params): + """Sub-function to get base parameters from a dictionary of parameters.""" + + return {key : value for key, value in params.items() if key in BASE_PARAMS} + + +def drop_base_params(params): + """Drop base parameters from a parameter definition. + + Parameters + ---------- + params : dict or list of dict + Parameter definition(s). + + Returns + ------- + params : dict or list of dict + Parameter definition(s), excluding base parameters. + """ + + if isinstance(params, dict): + params = _drop_base_params(params) + elif isinstance(params, list): + params = [_drop_base_params(cparams) for cparams in params] + else: + raise ValueError('Parameter definition not understood.') + + return params + + +def _drop_base_params(params): + """Sub-function to drop base parameters from a dictionary of parameters.""" + + return {key : value for key, value in params.items() if key not in BASE_PARAMS} + + +def get_param_values(params, extract=None, component=None): + """Get a set of parameter values from a set of parameter definitions. + + Parameters + ---------- + params : list of dict + Parameter definitions for multiple simulations. + extract : str + Name of the parameter to extract. + component : str, optional + Which component to extract the parameter from. + Only used if the parameter definition is for a multi-component simulation. + + Returns + ------- + values : list + Extracted parameter values. + """ + + if component: + values =[cparams['components'][component][extract] for cparams in params] + elif extract: + values = [cparams[extract] for cparams in params] + else: + values = None + + return values + + +## SIMULATION PARAMETER OBJECTS + class SimParams(): """Object for managing simulation parameters. @@ -50,6 +147,18 @@ def __getitem__(self, label): return {**self.base, **self._params[label]} + def __contains__(self, label): + """Define object contents as whether label exists in object. + + Parameters + ---------- + label : str + Label to check whether it exists in object. + """ + + return label in self.labels + + @property def base(self): """Get the base parameters, common across all simulations. @@ -91,12 +200,12 @@ def params(self): return {label : {**self.base, **params} for label, params in self._params.items()} - def make_params(self, parameters=None, **kwargs): + def make_params(self, params=None, **kwargs): """Make a simulation parameter definition from given parameters. Parameters ---------- - parameters : dict or list of dict + params : dict or list of dict Parameter definition(s) to create simulation parameter definition. **kwargs Additional keyword arguments to create the simulation definition. @@ -107,23 +216,23 @@ def make_params(self, parameters=None, **kwargs): Parameter definition. """ - return {**self.base, **self._make_params(parameters, **kwargs)} + return {**self.base, **self._make_params(params, **kwargs)} - def register(self, label, parameters=None, **kwargs): + def register(self, label, params=None, **kwargs): """Register a new simulation parameter definition. Parameters ---------- label : str Label to set simulation parameters under in `params`. - parameters : dict or list of dict + params : dict or list of dict Parameter definition(s) to create simulation parameter definition. **kwargs Additional keyword arguments to create the simulation definition. """ - self._params[label] = self._make_params(parameters, **kwargs) + self._params[label] = self._make_params(params, **kwargs) def register_group(self, group, clear=False): @@ -141,8 +250,8 @@ def register_group(self, group, clear=False): if clear: self.clear() - for label, parameters in group.items(): - self.register(label, parameters) + for label, params in group.items(): + self.register(label, params) def update_base(self, n_seconds=False, fs=False): @@ -240,27 +349,39 @@ def copy(self): return deepcopy(self) - def _make_params(self, parameters=None, **kwargs): - """Sub-function for `make_params`.""" + def _make_params(self, params=None, **kwargs): + """Sub-function to make parameter definition. - parameters = {} if not parameters else deepcopy(parameters) + Parameters + ---------- + params : dict or list of dict + Parameter definition(s) to create simulation parameter definition. + **kwargs + Additional keyword arguments to create the simulation definition. - if isinstance(parameters, list): - comps = [parameters.pop(0)] - kwargs = {**kwargs, **parameters[0]} if parameters else kwargs - params = self._make_combined_params(comps, **kwargs) + Returns + ------- + params : dict + Parameter definition. + """ + + params = {} if not params else deepcopy(params) + + if isinstance(params, list): + comps = [params.pop(0)] + kwargs = {**kwargs, **params[0]} if params else kwargs + out_params = self._make_combined_params(comps, **kwargs) else: - params = {**parameters, **kwargs} + out_params = {**params, **kwargs} # If any base parameters were passed in, clear them - for bparam in self.base: - params.pop(bparam, None) + out_params = drop_base_params(out_params) - return params + return out_params def _make_combined_params(self, components, component_variances=None): - """Make parameters for combined simulations, specifying multiple components. + """Sub-function to make parameters for combined simulations, specifying multiple components. Parameters ---------- @@ -275,17 +396,17 @@ def _make_combined_params(self, components, component_variances=None): Parameter definition. """ - parameters = {} + out_params = {} comps = {} for comp in components: comps.update(**deepcopy(comp)) - parameters['components'] = comps + out_params['components'] = comps if component_variances: - parameters['component_variances'] = component_variances + out_params['component_variances'] = component_variances - return parameters + return out_params class SimIters(SimParams): @@ -368,6 +489,8 @@ def make_iter(self, label, update, values, component=None): Generator object for iterating across simulation parameters. """ + from neurodsp.sim.update import ParamIter + assert label in self._params.keys(), "Label for simulation parameters not found." return ParamIter(super().__getitem__(label), update, values, component) @@ -532,6 +655,8 @@ def make_sampler(self, label, samplers, n_samples=None): Generator object for sampling simulation parameters. """ + from neurodsp.sim.update import ParamSampler + return ParamSampler(super().__getitem__(label), samplers, n_samples if n_samples else self.n_samples) diff --git a/neurodsp/sim/signals.py b/neurodsp/sim/signals.py index 5933b7c8..36fcd378 100644 --- a/neurodsp/sim/signals.py +++ b/neurodsp/sim/signals.py @@ -5,7 +5,9 @@ import numpy as np from neurodsp.utils.core import listify -from neurodsp.sim.utils import get_base_params, drop_base_params +from neurodsp.utils.data import compute_nsamples +from neurodsp.sim.info import get_sim_func_name +from neurodsp.sim.params import get_base_params, drop_base_params, get_param_values ################################################################################################### ################################################################################################### @@ -15,11 +17,12 @@ class Simulations(): Parameters ---------- - signals : 1d or 2nd array, optional - The simulated signals, organized as [n_sims, sig_length]. + signals : 1d or 2nd array or int, optional + If array, the simulated signals, organized as [n_sims, sig_length]. + If int, the number of expected simulations, used to pre-initialize array. params : dict, optional The simulation parameters that were used to create the simulations. - sim_func : str or callable, optional + function : str or callable, optional The simulation function that was used to create the simulations. If callable, the name of the function is taken to be added to the object. @@ -28,14 +31,22 @@ class Simulations(): This object stores a set of simulations generated from a shared parameter definition. """ - def __init__(self, signals=None, params=None, sim_func=None): + def __init__(self, signals=None, params=None, function=None): """Initialize Simulations object.""" - self.signals = np.atleast_2d(signals) if signals is not None else np.array([]) + if signals is None: + signals = np.array([]) + elif isinstance(signals, int): + n_samples = compute_nsamples(params['n_seconds'], params['fs']) + signals = np.zeros((signals, n_samples)) + self.signals = np.atleast_2d(signals) + self._base_params = None self._params = None self.add_params(params) - self.sim_func = sim_func.__name__ if callable(sim_func) else sim_func + + self.function = get_sim_func_name(function) + def __iter__(self): """Define iteration as stepping across individual simulated signals.""" @@ -43,28 +54,33 @@ def __iter__(self): for sig in self.signals: yield sig + def __getitem__(self, ind): """Define indexing as accessing simulated signals.""" return self.signals[ind, :] + def __len__(self): """Define the length of the object as the number of signals.""" return len(self.signals) + @property def n_seconds(self): """Alias n_seconds as a property attribute from base parameters.""" return self._base_params['n_seconds'] if self.has_params else None + @property def fs(self): """Alias fs as a property attribute from base parameters.""" return self._base_params['fs'] if self.has_params else None + @property def params(self): """Define the full set of simulation parameters (base + additional parameters).""" @@ -76,24 +92,27 @@ def params(self): return params + @property def has_params(self): """Indicator for if the object has parameters.""" return bool(self._params) + @property def has_signals(self): """Indicator for if the object has signals.""" return bool(len(self)) + def add_params(self, params): """Add parameter definition to object. Parameters ---------- - params : dict, optional + params : dict or None The simulation parameter definition(s). """ @@ -102,27 +121,62 @@ def add_params(self, params): self._params = drop_base_params(params) -class SampledSimulations(Simulations): - """Data object for a set of simulated signals with sampled (variable) parameter definitions. + def add_signal(self, signal, index=None): + """Add a signal to the current object. + + Parameters + ---------- + signal : 1d array + A simulated signal to add to the object. + index : int + Index to insert the new signal in the signals attribute. + """ + + if index is not None: + self.signals[index, :] = signal + else: + if not self.signals.size: + self.signals = np.atleast_2d(signal) + else: + try: + self.signals = np.vstack([self.signals, signal]) + except ValueError as array_value_error: + msg = 'Size of the added signal is not consistent with existing signals.' + raise ValueError(msg) from array_value_error + + +class VariableSimulations(Simulations): + """Data object for a set of simulated signals with variable parameter definitions. Parameters ---------- - signals : 2nd array, optional - The simulated signals, organized as [n_sims, sig_length]. + signals : 2nd array or int, optional + If array, the simulated signals, organized as [n_sims, sig_length]. + If int, the number of expected simulations, used to pre-initialize array. params : list of dict, optional The simulation parameters for each of the simulations. - sim_func : str, optional + function : str, optional The simulation function that was used to create the simulations. + update : str + The name of the parameter that is updated across simulations. + component : str + Which component the updated parameter is part of. + Only used if the parameter definition is for a multi-component simulation. Notes ----- This object stores a set of simulations with different parameter definitions per signal. """ - def __init__(self, signals=None, params=None, sim_func=None): + def __init__(self, signals=None, params=None, function=None, update=None, component=None): """Initialize SampledSimulations object.""" - Simulations.__init__(self, signals, params, sim_func) + Simulations.__init__(self, signals, params, function) + if isinstance(signals, int): + self._params = [{}] * signals + self.update = update + self.component = component + @property def n_seconds(self): @@ -130,12 +184,14 @@ def n_seconds(self): return self.params[0].n_seconds if self.has_params else None + @property def fs(self): """Alias fs as a property.""" return self.params[0].fs if self.has_params else None + @property def params(self): """Define simulation parameters (base + additional parameters) for each simulation.""" @@ -147,36 +203,55 @@ def params(self): return params - def add_params(self, params): + + @property + def values(self): + """Alias in the parameter definition of the parameter that varies across the sets.""" + + return get_param_values(self.params, self.update, self.component) + + + def add_params(self, params, index=None): """Add parameter definition(s) to object. Parameters ---------- params : dict or list of dict, optional The simulation parameter definition(s). + index : int + Index to insert the new parameter definition. """ if params: params = listify(params) - base_params = get_base_params(params[0]) - cparams = [drop_base_params(el) for el in params] - if not self.has_params: - if len(self) > len(cparams): - msg = 'Cannot add parameters to object without existing parameter values.' - raise ValueError(msg) + base_params = get_base_params(params[0]) + if not self._base_params: self._base_params = base_params - self._params = cparams - else: - self._params.extend(cparams) + msg = 'Base params have to match existing parameters.' + assert base_params == self._base_params, msg + + cparams = [drop_base_params(el) for el in params] + if cparams[0]: + if not self.has_params: + if len(self) > 1 and len(self) > len(cparams): + msg = 'Cannot add parameters to object without existing parameter values.' + raise ValueError(msg) + self._params = cparams + else: + if index is not None: + self._params[index] = cparams[0] + else: + self._params.extend(cparams) else: if self.has_params: raise ValueError('Must add parameters if object already has them.') - def add_signal(self, signal, params=None): + + def add_signal(self, signal, params=None, index=None): """Add a signal to the current object. Parameters @@ -187,14 +262,12 @@ def add_signal(self, signal, params=None): Parameter definition for the added signal. If current object does not include parameters, should be empty. If current object does include parameters, this input is required. + index : int + Index to insert the new signal in the signals attribute. """ - try: - self.signals = np.vstack([self.signals, signal]) - except ValueError as array_value_error: - msg = 'Size of the added signal is not consistent with existing signals.' - raise ValueError(msg) from array_value_error - self.add_params(params) + super().add_signal(signal, index=index) + self.add_params(params, index=index) class MultiSimulations(): @@ -206,22 +279,27 @@ class MultiSimulations(): Sets of simulated signals, with each array organized as [n_sims, sig_length]. params : list of dict The simulation parameters that were used to create the simulations. - sim_func : str or list of str + function : str or list of str The simulation function(s) that were used to create the simulations. update : str The name of the parameter that is updated across sets of simulations. + component : str + Which component the updated parameter is part of. + Only used if the parameter definition is for a multi-component simulation. Notes ----- This object stores a set of simulations with multiple instances per parameter definition. """ - def __init__(self, signals=None, params=None, sim_func=None, update=None): + def __init__(self, signals=None, params=None, function=None, update=None, component=None): """Initialize MultiSimulations object.""" self.signals = [] - self.add_signals(signals, params, sim_func) + self.add_signals(signals, params, function) self.update = update + self.component = component + def __iter__(self): """Define iteration as stepping across sets of simulated signals.""" @@ -229,33 +307,39 @@ def __iter__(self): for sigs in self.signals: yield sigs + def __getitem__(self, index): """Define indexing as accessing sets of simulated signals.""" return self.signals[index] + def __len__(self): """Define the length of the object as the number of sets of signals.""" return len(self.signals) + @property def n_seconds(self): """Alias n_seconds as a property.""" return self.signals[0].n_seconds if self else None + @property def fs(self): """Alias fs as a property.""" return self.signals[0].fs if self else None + @property - def sim_func(self): + def function(self): """Alias func as property.""" - return self.signals[0].sim_func if self else None + return self.signals[0].function if self else None + @property def params(self): @@ -265,16 +349,13 @@ def params(self): return params + @property def values(self): """Alias in the parameter definition of the parameter that varies across the sets.""" - if self.update: - values = [params[self.update] for params in self.params] - else: - values = None + return get_param_values(self.params, self.update, self.component) - return values @property def _base_params(self): @@ -282,13 +363,15 @@ def _base_params(self): return self.signals[0]._base_params if self else None + @property def has_signals(self): """Indicator for if the object has signals.""" return bool(len(self)) - def add_signals(self, signals, params=None, sim_func=None): + + def add_signals(self, signals, params=None, function=None): """Add a set of signals to the current object. Parameters @@ -297,7 +380,7 @@ def add_signals(self, signals, params=None, sim_func=None): A set of simulated signals, organized as [n_sims, sig_length]. params : dict or list of dict, optional The simulation parameters that were used to create the set of simulations. - sim_func : str, optional + function : str, optional The simulation function that was used to create the set of simulations. """ @@ -314,7 +397,7 @@ def add_signals(self, signals, params=None, sim_func=None): else: params = repeat(params) if not isinstance(params, list) else params - sim_func = repeat(sim_func) if not isinstance(sim_func, list) else sim_func - for csigs, cparams, cfunc in zip(signals, params, sim_func): - signals = Simulations(csigs, params=cparams, sim_func=cfunc) + function = repeat(function) if not isinstance(function, list) else function + for csigs, cparams, cfunc in zip(signals, params, function): + signals = Simulations(csigs, params=cparams, function=cfunc) self.signals.append(signals) diff --git a/neurodsp/sim/update.py b/neurodsp/sim/update.py index c75db627..a1708a35 100644 --- a/neurodsp/sim/update.py +++ b/neurodsp/sim/update.py @@ -4,20 +4,46 @@ import numpy as np -from neurodsp.sim.multi import sig_yielder +from neurodsp.sim.generators import sig_yielder +from neurodsp.sim.params import get_base_params +from neurodsp.sim.info import get_sim_func from neurodsp.utils.core import counter ################################################################################################### ################################################################################################### +## BASE OBJECTS + +class BaseUpdater(): + """Base object for managing parameter and signal update objects. + + Parameters + ---------- + params : dict + Parameter definition. + """ + + def __init__(self, params): + """Initialize BaseUpdater object.""" + + self.params = deepcopy(params) + + + @property + def base(self): + """Alias in base parameters as property attribute.""" + + return get_base_params(self.params) + + ## PARAM UPDATERS -def param_updater(parameter): +def param_updater(param): """Create a lambda updater function to update a specified parameter. Parameters ---------- - parameter : str + param : str Name of the parameter to update. Returns @@ -26,15 +52,15 @@ def param_updater(parameter): Updater function which can update specified parameter in simulation parameters. """ - return lambda params, value : params.update({parameter : value}) + return lambda params, value : params.update({param : value}) -def component_updater(parameter, component): +def component_updater(param, component): """Create a lambda updater function to update a parameter within a simulation component. Parameters ---------- - parameter : str + param : str Name of the parameter to update. component : str Name of the component to update the parameter within. @@ -45,7 +71,7 @@ def component_updater(parameter, component): Updater function which can update specified parameter in simulation parameters. """ - return lambda params, value : params['components'][component].update({parameter : value}) + return lambda params, value : params['components'][component].update({param : value}) def create_updater(update, component=None): @@ -82,12 +108,12 @@ def create_updater(update, component=None): ## PARAM ITER -def param_iter_yielder(sim_params, updater, values): +def param_iter_yielder(params, updater, values): """Parameter yielder. Parameters ---------- - sim_params : dict + params : dict Parameter definition. updater : callable Updater function to update parameter definition. @@ -96,18 +122,18 @@ def param_iter_yielder(sim_params, updater, values): Yields ------ - sim_params : dict + params : dict Simulation parameter definition. """ - sim_params = deepcopy(sim_params) + params = deepcopy(params) for value in values: - updater(sim_params, value) - yield deepcopy(sim_params) + updater(params, value) + yield deepcopy(params) -class ParamIter(): +class ParamIter(BaseUpdater): """Object for iterating across parameter updates. Parameters @@ -134,13 +160,13 @@ def __init__(self, params, update, values, component=None): """Initialize parameter iteration object.""" params = deepcopy(params) - if component is not None: params['components'][component][update] = None else: params[update] = None - self.params = params + BaseUpdater.__init__(self, params) + self.update = update self.values = values self.component = component @@ -225,12 +251,12 @@ def create_sampler(values, probs=None, n_samples=None): yield np.random.choice(values, p=probs) -def param_sample_yielder(sim_params, samplers, n_samples=None): +def param_sample_yielder(params, samplers, n_samples=None): """Generator to yield randomly sampled parameter definitions. Parameters ---------- - sim_params : dict + params : dict The parameters for the simulated signal. samplers : dict Sampler definitions to update parameters with. @@ -242,19 +268,19 @@ def param_sample_yielder(sim_params, samplers, n_samples=None): Yields ------ - sim_params : dict + params : dict Simulation parameter definition. """ for _ in counter(n_samples): - out_params = deepcopy(sim_params) + out_params = deepcopy(params) for updater, sampler in samplers.items(): updater(out_params, next(sampler)) yield out_params -class ParamSampler(): +class ParamSampler(BaseUpdater): """Object for sampling parameter definitions. Parameters @@ -280,7 +306,8 @@ class ParamSampler(): def __init__(self, params, samplers, n_samples=None): """Initialize parameter sampler object.""" - self.params = deepcopy(params) + BaseUpdater.__init__(self, params) + self.samplers = samplers self.n_samples = n_samples @@ -314,16 +341,24 @@ def _reset_yielder(self): self.yielder = param_sample_yielder(self.params, self.samplers, self.n_samples) + @property + def base(self): + """Alias in base parameters as property attribute.""" + + return get_base_params(self.params) + + ## SIG ITER -class SigIter(): +class SigIter(BaseUpdater): """Object for iterating across sampled simulations. Parameters ---------- - sim_func : callable + function : str or callable Function to create simulations. - sim_params : dict + If string, should be the name of the desired simulation function. + params : dict Simulation parameters. n_sims : int, optional Number of simulations to create. @@ -337,11 +372,12 @@ class SigIter(): Generator for sampling the sig iterations. """ - def __init__(self, sim_func, sim_params, n_sims=None): + def __init__(self, function, params, n_sims=None): """Initialize signal iteration object.""" - self.sim_func = sim_func - self.sim_params = deepcopy(sim_params) + BaseUpdater.__init__(self, params) + + self.function = get_sim_func(function) self.n_sims = n_sims self.index = 0 @@ -375,4 +411,4 @@ def _reset_yielder(self): """Reset the object yielder.""" self.index = 0 - self.yielder = sig_yielder(self.sim_func, self.sim_params, self.n_sims) + self.yielder = sig_yielder(self.function, self.params, self.n_sims) diff --git a/neurodsp/sim/utils.py b/neurodsp/sim/utils.py index b11a5c55..fd77dd07 100644 --- a/neurodsp/sim/utils.py +++ b/neurodsp/sim/utils.py @@ -1,202 +1,4 @@ -"""Utility function for neurodsp.sim.""" +"""Simulation utilities.""" -import numpy as np - -from neurodsp.sim.info import get_sim_func -from neurodsp.utils.data import compute_nseconds - -################################################################################################### -################################################################################################### - -def rotate_timeseries(sig, fs, delta_exp, f_rotation=1): - """Rotate a timeseries of data, changing it's 1/f exponent. - - Parameters - ---------- - sig : 1d array - A time series to rotate. - fs : float - Sampling rate of the signal, in Hz. - delta_exp : float - Change in power law exponent to be applied. - Positive is clockwise rotation (steepen), negative is counter clockwise rotation (flatten). - f_rotation : float, optional, default: 1 - Frequency, in Hz, to rotate the spectrum around, where power is unchanged by the rotation. - - Returns - ------- - sig_rotated : 1d array - The rotated version of the signal. - - Notes - ----- - This function works by taking the FFT and spectrally rotating the input signal. - To return a timeseries, the rotated FFT is then turned back into a time series, with an iFFT. - - Examples - -------- - Rotate a timeseries of simulated data: - - >>> from neurodsp.sim import sim_combined - >>> sig = sim_combined(n_seconds=10, fs=500, - ... components={'sim_powerlaw': {}, 'sim_oscillation' : {'freq': 10}}) - >>> rotated_sig = rotate_timeseries(sig, fs=500, delta_exp=0.5) - """ - - # Compute the FFT - fft_vals = np.fft.fft(sig) - freqs = np.fft.fftfreq(len(sig), 1./fs) - - # Rotate the spectrum to create the exponent change - # Delta exponent is divided by two, as the FFT output is in units of amplitude not power - fft_rotated = rotate_spectrum(freqs, fft_vals, delta_exp/2, f_rotation) - - # Invert back to time series, with a z-score to normalize - sig_rotated = np.real(np.fft.ifft(fft_rotated)) - - return sig_rotated - - -def rotate_spectrum(freqs, spectrum, delta_exponent, f_rotation=1): - """Rotate the power law exponent of a power spectrum. - - Parameters - ---------- - freqs : 1d array - Frequency axis of input spectrum, in Hz. - spectrum : 1d array - Power spectrum to be rotated. - delta_exponent : float - Change in power law exponent to be applied. - Positive is clockwise rotation (steepen), negative is counter clockwise rotation (flatten). - f_rotation : float, optional, default: 1 - Frequency, in Hz, to rotate the spectrum around, where power is unchanged by the rotation. - This only matters if not further normalizing signal variance. - - Returns - ------- - rotated_spectrum : 1d array - Rotated spectrum. - - Notes - ----- - The input power spectrum is multiplied with a mask that applies the specified exponent change. - - Examples - -------- - Rotate a power spectrum, calculated on simulated data: - - >>> from neurodsp.sim import sim_combined - >>> from neurodsp.spectral import compute_spectrum - >>> sig = sim_combined(n_seconds=10, fs=500, - ... components={'sim_powerlaw': {}, 'sim_oscillation' : {'freq': 10}}) - >>> freqs, spectrum = compute_spectrum(sig, fs=500) - >>> rotated_spectrum = rotate_spectrum(freqs, spectrum, -1) - """ - - if freqs[0] == 0: - skipped_zero = True - f_0, p_0 = freqs[0], spectrum[0] - freqs, spectrum = freqs[1:], spectrum[1:] - else: - skipped_zero = False - - mask = (np.abs(freqs) / f_rotation)**-delta_exponent - rotated_spectrum = mask * spectrum - - if skipped_zero: - freqs = np.insert(freqs, 0, f_0) - rotated_spectrum = np.insert(rotated_spectrum, 0, p_0) - - return rotated_spectrum - - -def modulate_signal(sig, modulation, fs=None, mod_params=None): - """Apply amplitude modulation to a signal. - - Parameters - ---------- - sig : 1d array - A signal to modulate. - modulation : 1d array or str - Modulation to apply to the signal. - If array, the modulating signal to apply directly to the signal. - If str, a function name to use to simulate the modulating signal that will be applied. - fs : float, optional - Signal sampling rate, in Hz. - Only needed if `modulation` is a callable. - mod_params : dictionary, optional - Parameters for the modulation function. - Only needed if `modulation` is a callable. - - Returns - ------- - msig : 1d array - Amplitude modulated signal. - - Examples - -------- - Amplitude modulate a sinusoidal signal with a lower frequency, passing in a function label: - - >>> from neurodsp.sim import sim_oscillation - >>> fs = 500 - >>> sig = sim_oscillation(n_seconds=10, fs=fs, freq=10) - >>> msig = modulate_signal(sig, 'sim_oscillation', fs, {'freq' : 1}) - - Amplitude modulate a sinusoidal signal with precomputed 1/f signal: - - >>> from neurodsp.sim import sim_oscillation, sim_powerlaw - >>> n_seconds = 10 - >>> fs = 500 - >>> sig = sim_oscillation(n_seconds, fs, freq=10) - >>> mod = sim_powerlaw(n_seconds, fs, exponent=-1) - >>> msig = modulate_signal(sig, mod) - """ - - if isinstance(modulation, str): - mod_func = get_sim_func(modulation) - modulation = mod_func(compute_nseconds(sig, fs), fs, **mod_params) - - assert len(sig) == len(modulation), \ - 'Lengths of the signal and modulator must match to apply modulation' - - msig = sig * modulation - - return msig - -## Utilities for helping with parameter management - -BASE_PARAMS = ['n_seconds', 'fs'] - -def get_base_params(params): - """Get base parameters from a parameter definition. - - Parameters - ---------- - params : dict - Parameter definition. - - Returns - ------- - params : dict - Base parameters. - """ - - return {key : value for key, value in params.items() if key in BASE_PARAMS} - - -def drop_base_params(params): - """Drop base parameters from a parameter definition. - - Parameters - ---------- - params : dict - Parameter definition. - - Returns - ------- - params : dict - Parameter definition, exluding base parameters. - """ - - return {key : value for key, value in params.items() if key not in BASE_PARAMS} +# Alias in function that used to be here for backwards compatibility +from neurodsp.sim.modulate import rotate_timeseries, rotate_spectrum, modulate_signal diff --git a/neurodsp/spectral/__init__.py b/neurodsp/spectral/__init__.py index 050c103a..c63c4e41 100644 --- a/neurodsp/spectral/__init__.py +++ b/neurodsp/spectral/__init__.py @@ -5,4 +5,4 @@ from .measures import compute_absolute_power, compute_relative_power, compute_band_ratio from .variance import compute_scv, compute_scv_rs, compute_spectral_hist from .utils import trim_spectrum, trim_spectrogram -from ..sim.utils import rotate_spectrum as rotate_powerlaw +from ..sim.modulate import rotate_spectrum as rotate_powerlaw diff --git a/neurodsp/spectral/utils.py b/neurodsp/spectral/utils.py index 567417b9..b32614bc 100644 --- a/neurodsp/spectral/utils.py +++ b/neurodsp/spectral/utils.py @@ -4,7 +4,7 @@ from scipy.fft import next_fast_len # Alias a function that has moved, for backwards compatibility -from neurodsp.sim.utils import rotate_spectrum as rotate_powerlaw +from neurodsp.sim.modulate import rotate_spectrum as rotate_powerlaw ################################################################################################### ################################################################################################### diff --git a/neurodsp/tests/conftest.py b/neurodsp/tests/conftest.py index f2b82a83..37338e04 100644 --- a/neurodsp/tests/conftest.py +++ b/neurodsp/tests/conftest.py @@ -9,10 +9,11 @@ from neurodsp.sim import sim_oscillation, sim_powerlaw, sim_combined from neurodsp.sim.update import create_updater, create_sampler from neurodsp.sim.params import SimParams +from neurodsp.sim.signals import Simulations, VariableSimulations, MultiSimulations from neurodsp.spectral import compute_spectrum from neurodsp.utils.sim import set_random_seed from neurodsp.tests.settings import (N_SECONDS, FS, FREQ_SINE, FREQ1, EXP1, - BASE_TEST_FILE_PATH, TEST_PLOTS_PATH) + BASE_TEST_FILE_PATH, TEST_PLOTS_PATH, TEST_FILES_PATH) ################################################################################################### ################################################################################################### @@ -62,8 +63,8 @@ def tsim_params(): sim_params = SimParams(N_SECONDS, FS) sim_params.register_group({ - 'pl' : {'sim_powerlaw' : {'exponent' : -1}}, - 'osc' : {'sim_oscillation' : {'freq' : -1}}, + 'pl' : {'exponent' : -1}, + 'osc' : {'freq' : -1}, }) yield sim_params @@ -72,7 +73,7 @@ def tsim_params(): def tsim_iters(tsim_params): sim_iters = tsim_params.to_iters() - sim_iters.register_iter('pl_exp', 'pl', 'exponent', [-2, -1, 0]) + sim_iters.register_iter('pl_exp', 'pl', 'exponent', [-2, -1]) yield sim_iters @@ -81,10 +82,27 @@ def tsim_samplers(tsim_params): sim_samplers = tsim_params.to_samplers() sim_samplers.register_sampler(\ - 'samp_exp', 'pl', {create_updater('exponent') : create_sampler([-2, -1, 0])}) + 'samp_exp', 'pl', {create_updater('exponent') : create_sampler([-2, -1])}) yield sim_samplers +@pytest.fixture(scope='session') +def tsims(tsig2d, tsim_params): + + yield Simulations(tsig2d, tsim_params['pl'], 'sim_test') + +@pytest.fixture(scope='session') +def tvsims(tsig2d, tsim_iters): + + params = [ps for ps in tsim_iters.iters['pl_exp'].yielder] + yield VariableSimulations(tsig2d, params, 'sim_test', tsim_iters.iters['pl_exp'].update) + +@pytest.fixture(scope='session') +def tmsims(tsig2d, tsim_iters): + + params = [ps for ps in tsim_iters.iters['pl_exp'].yielder] + yield MultiSimulations([tsig2d, tsig2d], params, 'sim_test', tsim_iters.iters['pl_exp'].update) + @pytest.fixture(scope='session', autouse=True) def check_dir(): """Once, prior to session, this will clear and re-initialize the test file directories.""" @@ -96,3 +114,4 @@ def check_dir(): # Remake (empty) directories os.mkdir(BASE_TEST_FILE_PATH) os.mkdir(TEST_PLOTS_PATH) + os.mkdir(TEST_FILES_PATH) diff --git a/neurodsp/tests/settings.py b/neurodsp/tests/settings.py index 3d605d75..bf328b41 100644 --- a/neurodsp/tests/settings.py +++ b/neurodsp/tests/settings.py @@ -37,4 +37,5 @@ # Set paths for test files TESTS_PATH = Path(os.path.abspath(os.path.dirname(__file__))) BASE_TEST_FILE_PATH = TESTS_PATH / 'test_files' -TEST_PLOTS_PATH = os.path.join(BASE_TEST_FILE_PATH, 'plots') +TEST_PLOTS_PATH = BASE_TEST_FILE_PATH / 'plots' +TEST_FILES_PATH = BASE_TEST_FILE_PATH / 'files' diff --git a/neurodsp/tests/sim/test_generators.py b/neurodsp/tests/sim/test_generators.py new file mode 100644 index 00000000..df275120 --- /dev/null +++ b/neurodsp/tests/sim/test_generators.py @@ -0,0 +1,29 @@ +"""Tests for neurodsp.sim.generators.""" + +import numpy as np + +from neurodsp.sim.aperiodic import sim_powerlaw + +from neurodsp.sim.generators import * + +################################################################################################### +################################################################################################### + +def test_sig_yielder(): + + params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} + yielder = sig_yielder(sim_powerlaw, params, 2) + + for ind, sig in enumerate(yielder): + assert isinstance(sig, np.ndarray) + assert ind == 1 + +def test_sig_sampler(): + + params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, + {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] + sampler = sig_sampler(sim_powerlaw, params) + + for ind, sig in enumerate(sampler): + assert isinstance(sig, np.ndarray) + assert ind == 1 diff --git a/neurodsp/tests/sim/test_info.py b/neurodsp/tests/sim/test_info.py index 87e3e5df..60a47104 100644 --- a/neurodsp/tests/sim/test_info.py +++ b/neurodsp/tests/sim/test_info.py @@ -2,6 +2,8 @@ from pytest import raises +from neurodsp.sim.aperiodic import sim_powerlaw + from neurodsp.sim.info import * ################################################################################################### @@ -31,3 +33,12 @@ def test_get_sim_func(): # Check the error for requesting non-existing function with raises(ValueError): get_sim_func('bad_func') + +def test_get_sim_func_name(): + + in_name = 'sim_oscillation' + name1 = get_sim_func_name(in_name) + assert name1 == in_name + + name2 = get_sim_func_name(sim_powerlaw) + assert name2 == 'sim_powerlaw' diff --git a/neurodsp/tests/sim/test_io.py b/neurodsp/tests/sim/test_io.py new file mode 100644 index 00000000..2d2f4b66 --- /dev/null +++ b/neurodsp/tests/sim/test_io.py @@ -0,0 +1,112 @@ +"""Tests for neurodsp.sim.io.""" + +import os +from pathlib import Path + +from neurodsp.tests.settings import TEST_FILES_PATH + +from neurodsp.sim.io import * + +################################################################################################### +################################################################################################### + +def test_fpath(): + + out1 = fpath(None, 'file_name') + assert isinstance(out1, Path) + assert str(out1) == 'file_name' + + out2 = fpath('path', 'file_name') + assert isinstance(out2, Path) + assert str(out2) == 'path/file_name' + +def test_save_json(): + + data = {'a' : 1, 'b' : 2} + fname = 'test_json_file.json' + save_json(TEST_FILES_PATH / fname, data) + assert os.path.exists(TEST_FILES_PATH / fname) + +def test_save_jsonlines(): + + data = [{'a' : 1, 'b' : 2}, {'a' : 10, 'b' : 20}] + fname = 'test_jsonlines_file.json' + save_jsonlines(TEST_FILES_PATH / fname, data) + assert os.path.exists(TEST_FILES_PATH / fname) + +def test_load_json(): + + data_saved = {'a' : 1, 'b' : 2} + fname = 'test_json_file.json' + data_loaded = load_json(TEST_FILES_PATH / fname) + assert data_loaded == data_saved + +def test_load_jsonlines(): + + data_saved = [{'a' : 1, 'b' : 2}, {'a' : 10, 'b' : 20}] + fname = 'test_jsonlines_file.json' + data_loaded = load_jsonlines(TEST_FILES_PATH / fname) + assert data_loaded == data_saved + +def test_save_sims_sim(tsims): + + label = 'tsims' + folder = '_'.join([tsims.function.replace('_', '-'), label]) + + save_sims(tsims, label, TEST_FILES_PATH) + assert os.path.exists(TEST_FILES_PATH / folder) + assert os.path.exists(TEST_FILES_PATH / folder / 'params.json') + assert os.path.exists(TEST_FILES_PATH / folder / 'signals.npy') + +def test_load_sims_sim(tsims): + # Loads and tests object saved from `test_save_sims_sim` + + loaded_sims = load_sims('tsims', TEST_FILES_PATH) + assert np.array_equal(loaded_sims.signals, tsims.signals) + assert loaded_sims.function == tsims.function + assert loaded_sims.params == tsims.params + +def test_save_sims_vsim(tvsims): + + label = 'tvsims' + folder = '_'.join([tvsims.function.replace('_', '-'), tvsims.update, label]) + + save_sims(tvsims, label, TEST_FILES_PATH) + assert os.path.exists(TEST_FILES_PATH / folder) + assert os.path.exists(TEST_FILES_PATH / folder / 'params.jsonlines') + assert os.path.exists(TEST_FILES_PATH / folder / 'signals.npy') + +def test_load_sims_vsim(tvsims): + # Loads and tests object saved from `test_save_sims_vsim` + + loaded_sims = load_sims('tvsims', TEST_FILES_PATH) + assert np.array_equal(loaded_sims.signals, tvsims.signals) + assert loaded_sims.function == tvsims.function + assert loaded_sims.params == tvsims.params + assert loaded_sims.update == tvsims.update + assert loaded_sims.component == tvsims.component + +def test_save_sims_msim(tmsims): + + label = 'tmsims' + folder = '_'.join([tmsims.function.replace('_', '-'), tmsims.update, label]) + sub_folder = '_'.join([tmsims.function.replace('_', '-'), 'set']) + + save_sims(tmsims, label, TEST_FILES_PATH) + assert os.path.exists(TEST_FILES_PATH / folder) + for ind in range(len(tmsims)): + assert os.path.exists(TEST_FILES_PATH / folder / (sub_folder + str(ind))) + assert os.path.exists(TEST_FILES_PATH / folder / (sub_folder + str(ind)) / 'params.json') + assert os.path.exists(TEST_FILES_PATH / folder / (sub_folder + str(ind)) / 'signals.npy') + +def test_load_sims_msim(tmsims): + # Loads and tests object saved from `test_save_sims_msim` + + label = 'tmsims' + loaded_sims = load_sims(label, TEST_FILES_PATH) + assert loaded_sims.function == tmsims.function + assert loaded_sims.params == tmsims.params + assert loaded_sims.update == tmsims.update + assert loaded_sims.component == tmsims.component + for lsig, csig in zip(loaded_sims.signals, tmsims.signals): + assert np.array_equal(lsig.signals, csig.signals) diff --git a/neurodsp/tests/sim/test_utils.py b/neurodsp/tests/sim/test_modulate.py similarity index 67% rename from neurodsp/tests/sim/test_utils.py rename to neurodsp/tests/sim/test_modulate.py index 4467987f..9c733838 100644 --- a/neurodsp/tests/sim/test_utils.py +++ b/neurodsp/tests/sim/test_modulate.py @@ -1,11 +1,11 @@ -"""Tests for neurodsp.sim.utils.""" +"""Tests for neurodsp.sim.modulate.""" import numpy as np from neurodsp.tests.tutils import check_sim_output from neurodsp.tests.settings import FS -from neurodsp.sim.utils import * +from neurodsp.sim.modulate import * ################################################################################################### ################################################################################################### @@ -34,18 +34,3 @@ def test_modulate_signal(tsig): # Check modulation passing in a 1d array directly msig2 = modulate_signal(tsig, tsig) check_sim_output(msig2) - -def test_get_base_params(): - - params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} - out1 = get_base_params(params) - for bparam in out1: - assert bparam in BASE_PARAMS - -def test_drop_base_params(): - - params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} - out1 = drop_base_params(params) - for bparam in BASE_PARAMS: - assert bparam not in out1 - assert 'exponent' in params diff --git a/neurodsp/tests/sim/test_multi.py b/neurodsp/tests/sim/test_multi.py index eef94727..2809adf1 100644 --- a/neurodsp/tests/sim/test_multi.py +++ b/neurodsp/tests/sim/test_multi.py @@ -4,62 +4,61 @@ from neurodsp.sim.aperiodic import sim_powerlaw from neurodsp.sim.update import create_updater, create_sampler, ParamSampler -from neurodsp.sim.signals import Simulations, SampledSimulations, MultiSimulations +from neurodsp.sim.signals import Simulations, VariableSimulations, MultiSimulations from neurodsp.sim.multi import * ################################################################################################### ################################################################################################### -def test_sig_yielder(): - - params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} - yielder = sig_yielder(sim_powerlaw, params, 2) - - for ind, sig in enumerate(yielder): - assert isinstance(sig, np.ndarray) - assert ind == 1 - -def test_sig_sampler(): - - params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, - {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] - sampler = sig_sampler(sim_powerlaw, params) - - for ind, sig in enumerate(sampler): - assert isinstance(sig, np.ndarray) - assert ind == 1 - def test_sim_multiple(): n_sims = 2 params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} - sims_obj = sim_multiple(sim_powerlaw, params, n_sims, 'object') + sims_obj = sim_multiple(sim_powerlaw, params, n_sims) assert isinstance(sims_obj, Simulations) assert sims_obj.signals.shape[0] == n_sims assert sims_obj.params == params - sims_arr = sim_multiple(sim_powerlaw, params, n_sims, 'array') - assert isinstance(sims_arr, np.ndarray) - assert sims_arr.shape[0] == n_sims +def test_sim_across_values(tsim_iters): + + params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, + {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] + + sims_obj = sim_across_values(sim_powerlaw, params) + assert isinstance(sims_obj, VariableSimulations) + assert len(sims_obj) == len(params) + for csim, cparams, oparams in zip(sims_obj, sims_obj.params, params): + assert isinstance(csim, np.ndarray) + assert cparams == oparams + + # Test with ParamIter input + siter = tsim_iters['pl_exp'] + sims_iter = sim_across_values(sim_powerlaw, siter) + assert isinstance(sims_iter, VariableSimulations) + assert sims_iter.update == siter.update + assert sims_iter.values == siter.values -def test_sim_across_values(): +def test_sim_multi_across_values(tsim_iters): n_sims = 3 params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] - sims_obj = sim_across_values(sim_powerlaw, params, n_sims, 'object') + sims_obj = sim_multi_across_values(sim_powerlaw, params, n_sims) assert isinstance(sims_obj, MultiSimulations) - for sigs, cparams in zip(sims_obj, params): - assert isinstance(sigs, Simulations) - assert len(sigs) == n_sims - assert sigs.params == cparams - - sigs_arr = sim_across_values(sim_powerlaw, params, n_sims, 'array') - assert isinstance(sigs_arr, np.ndarray) - assert sigs_arr.shape[0:2] == (len(params), n_sims) + for sims, cparams in zip(sims_obj, params): + assert isinstance(sims, Simulations) + assert len(sims) == n_sims + assert sims.params == cparams + + # Test with ParamIter input + siter = tsim_iters['pl_exp'] + sims_iter = sim_multi_across_values(sim_powerlaw, siter, n_sims) + assert isinstance(sims_iter, MultiSimulations) + assert sims_iter.update == siter.update + assert sims_iter.values == siter.values def test_sim_from_sampler(): @@ -68,11 +67,7 @@ def test_sim_from_sampler(): samplers = {create_updater('exponent') : create_sampler([-2, -1, 0])} psampler = ParamSampler(params, samplers) - sims_obj = sim_from_sampler(sim_powerlaw, psampler, n_sims, 'object') - assert isinstance(sims_obj, SampledSimulations) + sims_obj = sim_from_sampler(sim_powerlaw, psampler, n_sims) + assert isinstance(sims_obj, VariableSimulations) assert sims_obj.signals.shape[0] == n_sims assert len(sims_obj.params) == n_sims - - sims_arr = sim_from_sampler(sim_powerlaw, psampler, n_sims, 'array') - assert isinstance(sims_arr, np.ndarray) - assert sims_arr.shape[0] == n_sims diff --git a/neurodsp/tests/sim/test_params.py b/neurodsp/tests/sim/test_params.py index c436b6d6..22023108 100644 --- a/neurodsp/tests/sim/test_params.py +++ b/neurodsp/tests/sim/test_params.py @@ -1,5 +1,7 @@ """Tests for neurodsp.sim.params.""" +from pytest import raises + from neurodsp.sim.update import create_updater, create_sampler from neurodsp.sim.params import * @@ -7,6 +9,61 @@ ################################################################################################### ################################################################################################### +## FUNCTION TESTS + +def test_get_base_params(tsim_iters): + + params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} + out1 = get_base_params(params) + for bparam in out1: + assert bparam in BASE_PARAMS + + params_lst = [params, params] + out2 = get_base_params(params) + for bparam in out2: + assert bparam in BASE_PARAMS + + out3 = get_base_params(tsim_iters['pl_exp']) + for bparam in out3: + assert bparam in BASE_PARAMS + + with raises(ValueError): + get_base_params('parameters') + +def test_drop_base_params(): + + params = {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1} + out1 = drop_base_params(params) + for bparam in BASE_PARAMS: + assert bparam not in out1 + assert 'exponent' in out1 + + params_lst = [params, params] + out2 = drop_base_params(params_lst) + for cparams in out2: + for bparam in BASE_PARAMS: + assert bparam not in cparams + assert 'exponent' in cparams + + with raises(ValueError): + drop_base_params('parameters') + +def test_get_param_values(): + + params = [{'n_seconds' : 2, 'fs' : 250, 'exponent' : -2}, + {'n_seconds' : 2, 'fs' : 250, 'exponent' : -1}] + assert get_param_values(params, 'exponent') == [-2, -1] + assert get_param_values(params, 'n_seconds') == [2, 2] + + params = [{'n_seconds' : 2, 'fs' : 250, 'components' : \ + {'sim_powerlaw' : {'exponent' : -2}, 'sim_oscillation' : {'freq' : 10}}}, + {'n_seconds' : 2, 'fs' : 250, 'components' : \ + {'sim_powerlaw' : {'exponent' : -1}, 'sim_oscillation' : {'freq' : 10}}}] + assert get_param_values(params, 'exponent', 'sim_powerlaw') == [-2, -1] + assert get_param_values(params, 'freq', 'sim_oscillation') == [10, 10] + +## CLASS TESTS + def test_sim_params(): # Test initialization @@ -19,14 +76,27 @@ def test_sim_params(): # Test registering new simulation parameter definition sps1.register('pl', comp1) + assert 'pl' in sps1 assert comp1.items() <= sps1['pl'].items() # Test registering a group of new simulation parameter definitions sps2 = SimParams(5, 250) sps2.register_group({'pl' : comp1, 'osc' : comp2}) + for label in ['pl', 'osc']: + assert label in sps2 assert comp1.items() <= sps2['pl'].items() assert comp2.items() <= sps2['osc'].items() + # Test clearing and re-registering + sps2.register_group({'pl2' : comp1, 'osc2' : comp2}, clear=True) + for old_label in ['pl', 'osc']: + with raises(KeyError): + sps2[old_label] + for label in ['pl2', 'osc2']: + assert label in sps2 + assert comp1.items() <= sps2['pl2'].items() + assert comp2.items() <= sps2['osc2'].items() + def test_sim_params_props(tsim_params): # Test properties @@ -36,7 +106,9 @@ def test_sim_params_props(tsim_params): # Test copy and clear ntsim = tsim_params.copy() assert ntsim != tsim_params - ntsim.clear() + ntsim.clear(True) + assert ntsim.params == {} + assert ntsim.base == {'n_seconds': None, 'fs': None} def test_sim_params_make_params(tsim_params): # Test the SimParams `make_` methods @@ -93,7 +165,7 @@ def test_sim_iters(): sis1 = SimIters(5, 250) sis1.register('pl', comp_plw) sis1.register_iter('pl_exp', 'pl', 'exponent', [-2, -1, 0]) - assert sis1['pl_exp'] + assert 'pl_exp' in sis1 assert sis1['pl_exp'].values == [-2, -1, 0] # Test registering a group of new simulation iterator definitions @@ -103,8 +175,19 @@ def test_sim_iters(): {'name' : 'pl_exp', 'label' : 'pl', 'update' : 'exponent', 'values' : [-2, -1 ,0]}, {'name' : 'osc_freq', 'label' : 'osc', 'update' : 'freq', 'values' : [10, 20, 30]}, ]) - assert sis2['pl_exp'] - assert sis2['osc_freq'] + for label in ['pl_exp', 'osc_freq']: + assert label in sis2 + + # Test clearing and re-registering group with list inputs + sis2.register_group_iters([ + ['pl_exp2', 'pl', 'exponent', [-2, -1 ,0]], + ['osc_freq2', 'osc', 'freq', [10, 20, 30]], + ], clear=True) + for old_label in ['pl_exp', 'osc_freq']: + with raises(KeyError): + sis2[old_label] + for label in ['pl_exp2', 'osc_freq2']: + assert label in sis2 def test_sim_iters_props(tsim_iters): @@ -115,7 +198,10 @@ def test_sim_iters_props(tsim_iters): # Test copy and clear ntiter = tsim_iters.copy() assert ntiter != tsim_iters - ntiter.clear() + ntiter.clear(True, True, True) + assert ntiter.iters == {} + assert ntiter.params == {} + assert ntiter.base == {'n_seconds': None, 'fs': None} def test_sim_iters_upd(tsim_iters): @@ -128,7 +214,7 @@ def test_sim_samplers(): sss1.register('pl', {'sim_powerlaw' : {'exponent' : -1}}) sss1.register_sampler(\ 'samp_exp', 'pl', {create_updater('exponent') : create_sampler([-2, -1, 0])}) - assert sss1['samp_exp'] is not None + assert 'samp_exp' in sss1 # Test registering a group of new simulation sampler definitions sss2 = SimSamplers(5, 250) @@ -142,8 +228,19 @@ def test_sim_samplers(): {'name' : 'samp_freq', 'label' : 'osc', 'samplers' : {create_updater('freq') : create_sampler([10, 20, 30])}}, ]) - assert sss2['samp_exp'] is not None - assert sss2['samp_freq'] is not None + for label in ['samp_exp', 'samp_freq']: + assert label in sss2 + + # Test clearing and re-registering group with list inputs + sss2.register_group_samplers([ + ['samp_exp2', 'pl', {create_updater('exponent') : create_sampler([-2, -1, 0])}], + ['samp_freq2', 'osc', {create_updater('freq') : create_sampler([10, 20, 30])}], + ], clear=True) + for old_label in ['samp_exp', 'samp_freq']: + with raises(KeyError): + sss2[old_label] + for label in ['samp_exp2', 'samp_freq2']: + assert label in sss2 def test_sim_samplers_props(tsim_samplers, tsim_params): @@ -151,10 +248,13 @@ def test_sim_samplers_props(tsim_samplers, tsim_params): assert tsim_samplers.labels assert tsim_samplers.samplers - # Can't directly copy object with generator - so regenerate + # Can't directly copy object with generator - so regenerate, and test clear ntsim = tsim_params.copy() ntsamp = ntsim.to_samplers() - ntsamp.clear() + ntsamp.clear(True, True, True) + assert ntsamp.samplers == {} + assert ntsamp.params == {} + assert ntsamp.base == {'n_seconds': None, 'fs': None} def test_sim_samplers_upd(tsim_samplers): diff --git a/neurodsp/tests/sim/test_signals.py b/neurodsp/tests/sim/test_signals.py index 1cc61758..a1e5f23f 100644 --- a/neurodsp/tests/sim/test_signals.py +++ b/neurodsp/tests/sim/test_signals.py @@ -4,6 +4,8 @@ import numpy as np +from neurodsp.sim.params import get_base_params + from neurodsp.sim.signals import * ################################################################################################### @@ -30,7 +32,7 @@ def test_simulations(): assert sims_data.fs is None assert sims_data.has_signals assert sims_data.params is None - assert sims_data.sim_func is None + assert sims_data.function is None # Test dunders - iter & getitem & indicators for el in sims_data: @@ -44,11 +46,21 @@ def test_simulations(): assert sims_full.has_signals assert sims_full.has_params -def test_sampled_simulations(): + # Test pre-initialization + sims_pre = Simulations(n_sigs, params, 'sim_func') + assert len(sims_pre) == n_sigs + assert np.sum(sims_pre.signals) == 0 + assert sims_pre.has_signals and sims_pre.has_params + for ind, sig in enumerate(sigs): + sims_pre.add_signal(sig, ind) + assert len(sims_pre) == n_sigs + assert np.sum(sims_pre.signals) != 0 + +def test_variable_simulations(): # Test empty initialization - sims_empty = SampledSimulations() - assert isinstance(sims_empty, SampledSimulations) + sims_empty = VariableSimulations() + assert isinstance(sims_empty, VariableSimulations) # Demo data n_seconds = 2 @@ -59,14 +71,14 @@ def test_sampled_simulations(): {'n_seconds' : n_seconds, 'fs' : fs, 'exponent' : -1}] # Test initialization with data only - sims_data = SampledSimulations(sigs) + sims_data = VariableSimulations(sigs) assert sims_data assert len(sims_data) == n_sigs assert sims_data.n_seconds is None assert sims_data.fs is None assert sims_data.has_signals assert sims_data.params is None - assert sims_data.sim_func is None + assert sims_data.function is None # Test dunders - iter & getitem for el in sims_data: @@ -74,24 +86,34 @@ def test_sampled_simulations(): assert np.all(sims_data[0]) # Test initialization with metadata - sims_full = SampledSimulations(sigs, params, 'sim_func') + sims_full = VariableSimulations(sigs, params, 'sim_func') assert len(sims_full) == n_sigs == len(sims_full.params) assert sims_full.params == params assert sims_full.has_signals assert sims_full.has_params -def test_sampled_simulations_add(): + # Test pre-initialization + sims_pre = VariableSimulations(n_sigs, get_base_params(params), 'sim_func') + assert len(sims_pre) == n_sigs + assert np.sum(sims_pre.signals) == 0 + assert sims_pre.has_signals and sims_pre.has_params + for ind, (sig, cparams) in enumerate(zip(sigs, params)): + sims_pre.add_signal(sig, cparams, ind) + assert len(sims_pre) == n_sigs + assert np.sum(sims_pre.signals) != 0 + +def test_variable_simulations_add(): sig = np.array([1, 2, 3, 4, 5]) params = {'n_seconds' : 1, 'fs' : 100, 'param' : 'value'} sig2 = np.array([1, 2, 3, 4, 5, 6, 7, 8]) params2 = {'n_seconds' : 2, 'fs' : 250, 'param' : 'value'} - sims_data1 = SampledSimulations(sig) + sims_data1 = VariableSimulations(sig) sims_data1.add_signal(sig) assert sims_data1.has_signals - sims_data2 = SampledSimulations(sig, params) + sims_data2 = VariableSimulations(sig, params) sims_data2.add_signal(sig, params) assert sims_data2.has_signals assert sims_data2.has_params @@ -100,17 +122,17 @@ def test_sampled_simulations_add(): ## ERROR CHECKS # Adding parameters with different base parameters - sims_data3 = SampledSimulations(sig, params) + sims_data3 = VariableSimulations(sig, params) with raises(ValueError): sims_data3.add_signal(sig2, params2) # Adding parameters without previous parameters - sims_data4 = SampledSimulations(sig) + sims_data4 = VariableSimulations(sig) with raises(ValueError): sims_data4.add_signal(sig, params) # Not adding parameters with previous parameters - sims_data4 = SampledSimulations(sig, params) + sims_data4 = VariableSimulations(sig, params) with raises(ValueError): sims_data4.add_signal(sig) @@ -138,7 +160,7 @@ def test_multi_simulations(): assert sims_data.fs is None assert sims_data.has_signals assert sims_data.params == [None] * n_sets - assert sims_data.sim_func is None + assert sims_data.function is None assert sims_data.values is None # Test dunders - iter & getitem & indicators @@ -153,8 +175,9 @@ def test_multi_simulations(): assert sims_full.has_signals for params_obj, params_org in zip(sims_full.params, params): assert params_obj == params_org - assert sims_full.sim_func + assert sims_full.function assert sims_full.values + assert sims_full._base_params def test_multi_simulations_add(): diff --git a/neurodsp/tests/sim/test_update.py b/neurodsp/tests/sim/test_update.py index 4f114738..0a89ebc1 100644 --- a/neurodsp/tests/sim/test_update.py +++ b/neurodsp/tests/sim/test_update.py @@ -7,6 +7,13 @@ ################################################################################################### ################################################################################################### +def test_class_base_updater(): + + params = {'n_seconds' : 10, 'fs' : 250, 'exponent' : None} + obj = BaseUpdater(params) + assert obj + assert obj.base + def test_param_updater(): params = {'n_seconds' : 10, 'fs' : 250, 'exponent' : None} @@ -36,11 +43,11 @@ def test_create_updater(): def test_param_iter_yielder(): - sim_params = {'n_seconds' : 5, 'fs' : 250, 'exponent' : None} + params = {'n_seconds' : 5, 'fs' : 250, 'exponent' : None} updater = create_updater('exponent') values = [-2, -1, 0] - iter_yielder = param_iter_yielder(sim_params, updater, values) + iter_yielder = param_iter_yielder(params, updater, values) for ind, params in enumerate(iter_yielder): assert isinstance(params, dict) for el in ['n_seconds', 'fs', 'exponent']: @@ -49,11 +56,11 @@ def test_param_iter_yielder(): def test_class_param_iter(): - sim_params = {'n_seconds' : 5, 'fs' : 250, 'exponent' : None} + params = {'n_seconds' : 5, 'fs' : 250, 'exponent' : None} update = 'exponent' values = [-2, -1, 0] - piter = ParamIter(sim_params, update, values) + piter = ParamIter(params, update, values) assert piter for ind, params in enumerate(piter): assert isinstance(params, dict) diff --git a/neurodsp/utils/checks.py b/neurodsp/utils/checks.py index 11541b4f..20f00790 100644 --- a/neurodsp/utils/checks.py +++ b/neurodsp/utils/checks.py @@ -30,8 +30,11 @@ def check_param_range(param, label, bounds): "It should be between {:1.1f} and {:1.1f}.".format(*bounds) raise ValueError(msg) + # Alias for non-breaking backwards compatibility -def check_param(param, label, bounds): check_param_range(param, label, bounds) +def check_param(param, label, bounds): + check_param_range(param, label, bounds) + def check_param_options(param, label, options): """Check a parameter value is one of the acceptable options. diff --git a/neurodsp/utils/core.py b/neurodsp/utils/core.py index 33c81230..d159d9d6 100644 --- a/neurodsp/utils/core.py +++ b/neurodsp/utils/core.py @@ -69,7 +69,7 @@ def listify(arg): # Embed all non-iterable parameters into a list # Note: deal with str as a special case of iterable that we want to embed - if not isinstance(arg, Iterable) or isinstance(arg, str) or isinstance(arg, dict): + if not isinstance(arg, Iterable) or isinstance(arg, (str, dict)): out = [arg] # Deal with special case of multi dimensional numpy arrays - want to embed without flattening elif isinstance(arg, np.ndarray) and np.ndim(arg) > 1: diff --git a/tutorials/sim/plot_06_SimParams.py b/tutorials/sim/plot_06_SimParams.py index 8044bc6a..d34dfa79 100644 --- a/tutorials/sim/plot_06_SimParams.py +++ b/tutorials/sim/plot_06_SimParams.py @@ -298,7 +298,7 @@ # Register a group of samplers to the object sim_samplers.register_group_samplers([ ['exp_sampler', 'ap', exp_upd_sampler], - ['osc_sampler', 'osc', osc_upd_sampler] + ['osc_sampler', 'osc', osc_upd_sampler], ]) ################################################################################################### diff --git a/tutorials/sim/plot_07_SimMulti.py b/tutorials/sim/plot_07_SimMulti.py index 21df0f7c..5ffea1a6 100644 --- a/tutorials/sim/plot_07_SimMulti.py +++ b/tutorials/sim/plot_07_SimMulti.py @@ -7,9 +7,11 @@ from neurodsp.sim.update import SigIter from neurodsp.sim.aperiodic import sim_powerlaw -from neurodsp.sim.multi import sim_multiple, sim_across_values, sim_from_sampler +from neurodsp.sim.multi import (sim_multiple, sim_from_sampler, + sim_across_values, sim_multi_across_values) from neurodsp.sim.update import create_updater, create_sampler, ParamSampler from neurodsp.plts.time_series import plot_time_series, plot_multi_time_series +from neurodsp.utils.data import create_times ################################################################################################### # Simulate Multiple Signals Together @@ -29,19 +31,22 @@ ################################################################################################### # -# The output the above function is a :class:~.Simulations object that stores multiple simulated -# signals along with relevant metadata. +# The output the above function is a :class:~.Simulations object that stores multiple +# simulated signals along with relevant metadata. # ################################################################################################### # Check the metadata stored in the simulations object -print(sigs.sim_func, ':', sigs.params) +print(sigs.function, ':', sigs.params) ################################################################################################### +# Create a times definition corresponding to the simulations +times = create_times(params['n_seconds'], params['fs']) + # Plot the simulated signals -plot_multi_time_series(None, sigs) +plot_multi_time_series(times, sigs) ################################################################################################### # SigIter @@ -64,16 +69,59 @@ # Iterate with the object to create simulations for tsig in sig_iter: - plot_time_series(None, tsig) + plot_time_series(times, tsig) + +################################################################################################### +# Simulate From Sampler +# --------------------- +# +# We can also use the :func:`~.sim_from_sampler` function to simulate signals, +# sampling parameter values from a sampler definition. +# + +################################################################################################### + +# Define base set of parameters +params = {'n_seconds' : 5, 'fs' : 250, 'exponent' : None} + +# Create an updater and sampler to sample from +exp_sampler = {create_updater('exponent') : create_sampler([-2, -1, 0])} + +# Create a ParamSampler object +sampler = ParamSampler(params, exp_sampler) + +################################################################################################### + +# Simulate a set of signals from the defined sampler +sampled_sims = sim_from_sampler(sim_powerlaw, sampler, 3) + +################################################################################################### +# +# The output of the above is a :class:~.VariableSimulations object that stores simulations +# across variable simulation parameters, storing the simulated time series as well as the +# simulation parameters for each simulated signal. +# + +################################################################################################### + +# Check some of the metadata stored in the VariableSimulations object +print(sampled_sims.function) +for paramdef in sampled_sims.params: + print(paramdef) + +################################################################################################### + +# Plot the set of sampled simulations +plot_multi_time_series(times, sampled_sims) ################################################################################################### # Simulate Across Values # ---------------------- # -# Sometimes we may want to simulate signals across a set of parameter values. +# Sometimes we may want to simulate signals across a set defined range of parameter values. # -# To do so, we can use the :func:`~.sim_across_values` function. In doing so, we can define -# a set of parameter definitions and a number of simulations to create per definition. +# To do so, we can use the :func:`~.sim_across_values` function, which takes a definition of +# parameter values to simulate across. # ################################################################################################### @@ -86,72 +134,56 @@ ] # Simulate a set of signals -sims_across_params = sim_across_values(sim_powerlaw, multi_params, 3) +sims_across_params = sim_across_values(sim_powerlaw, multi_params) ################################################################################################### # -# The output of the above is a :class:~.MultiSimulations object that stores sets of simulations -# across different parameters, and relevant metadata. Each set of simulations is stored within -# this object as a :class:~.Simulations object. +# The output of the above is a :class:~.VariableSimulations object that stores simulations +# across varying simulation parameters (same as with the sampled simulations). # ################################################################################################### -# The length of the object is the number of parameter sets -print('# of sets of signals:', len(sims_across_params)) +# Plot the simulated time series from sampled parameters +plot_multi_time_series(times, sims_across_params) ################################################################################################### +# Simulate Multiple Instances Across Values +# ----------------------------------------- # -# In the above, we created a set of parameters per definition, which by default are returned -# in a dictionary. +# Finally, we may want to simulate multiple instances across a set of parameter definitions. +# +# To do so, we can use the :func:`~.sim_multi_across_values` function, which takes a set of +# parameter definitions and a number of simulations to create per definition. # ################################################################################################### -# Plot the simulated signals, accessing signals from each simulation definition -plot_multi_time_series(None, sims_across_params[0]) -plot_multi_time_series(None, sims_across_params[1]) -plot_multi_time_series(None, sims_across_params[2]) +# Simulate a set of signals +n_sims = 3 +sims_multi_across_params = sim_multi_across_values(sim_powerlaw, multi_params, n_sims) ################################################################################################### -# Simulate From Sampler -# --------------------- # -# Finally, we can use the :func:`~.sim_from_sampler` function to simulate signals, sampling -# parameter values from a sampler definition. +# The output of the above is a :class:~.MultiSimulations object that stores sets of simulations +# across different parameters, and relevant metadata. Each set of simulations is stored within +# this object as a :class:~.Simulations object. # ################################################################################################### -# Define base set of parameters -params = {'n_seconds' : 5, 'fs' : 250, 'exponent' : None} - -# Create an updater and sampler to sample from -exp_sampler = {create_updater('exponent') : create_sampler([-2, -1, 0])} - -# Create a ParamSampler object -sampler = ParamSampler(params, exp_sampler) - -################################################################################################### - -# Simulate a set of signals from the defined sampler -sampled_sims = sim_from_sampler(sim_powerlaw, sampler, 3) +# The length of the object is the number of parameter sets +print('# of sets of signals:', len(sims_across_params)) ################################################################################################### # -# The output of the above is a :class:~.SampledSimulations object that stores simulations -# created from sampled simulations, as well as the metadata, including the simulation -# parameters for each simulated signal. +# In the above, we created a set of parameters per definition, which by default are returned +# in a dictionary. # ################################################################################################### -# Check some of the metadata stored in the SampledSimulations object -print(sampled_sims.sim_func) -for paramdef in sampled_sims.params: - print(paramdef) - -################################################################################################### - -# Plot the set of sampled simulations -plot_multi_time_series(None, sampled_sims) +# Plot the simulated signals, accessing signals from each simulation definition +plot_multi_time_series(None, sims_across_params[0]) +plot_multi_time_series(None, sims_across_params[1]) +plot_multi_time_series(None, sims_across_params[2])