符号付きの周波数を通す IIR Filter
話すこと
IIR フィルタは周波数の符号に関係なく処理がされます。本記事では符号を考慮し、すなわち周波数の正か負を指定しフィルタをかけます。(
イメージはヒルベルト変換をして得る解析信号に近いです。解析信号は正の周波数のみを持ちますが、今回は複素信号を対象にしてフィルタをかけます。
実装として NumPy, SciPy を使います。
話さないこと
今回使用するフィルタは実係数を扱います。複素係数のフィルタを使い符号を意識したフィルタはできるかもしれませんが、今回は扱いません。
具体例
次式で定義される正の周波数と負の周波数が混在した複素信号を例にします。周波数
画像は元の信号の周波数ごとの強度と正の周波数を通すフィルタをかけた信号の強度です。青色が元の信号, オレンジ色がフィルタ後の信号の結果です。フィルタをかけることで負の周波数が減衰していることが分かります。
準備
アルゴリズム説明と実装を話す前にヒルベルト変換と正 (または負) の周波数を得るための演算方法を説明します。
1. ヒルベルト変換
記事 を参考にし、ヒルベルト変換後の信号
元の信号を
2. 正 (または負) の周波数を得る演算
正の周波数のみを残す下式を定義します。
前述のヒルベルト変換では
負の周波数のみを残す式を下式のように定義します。
先ほどと同様に考えると以下を得ます。
アルゴリズム・実装
概要
実装ではフィルタを 2つ使い
重要なのはヒルベルト変換で行ったように位相が
フィルタ作成
位相特性のみを変化させるオールパスフィルタを
これらの位相の差を
パラメータ
以下のようにフィルタを作成します。_fit_allpass_pair 関数がフィルタ作成関数です。
# 今回使用するライブラリ
import numpy as np
from numpy.typing import NDArray
from scipy import signal, optimize
def _a_from_fc(fc: float, fs: float) -> float:
"""オールパスフィルタの初期値を求める."""
k = np.tan(np.pi * float(fc) / float(fs))
a = (1 - k) / (1 + k)
return float(np.clip(a, -0.999, 0.999))
def _allpass_freqz(a: float, w: NDArray) -> NDArray:
"""オールパスフィルタの各項を求める."""
z = np.exp(1j * w)
return (a + z**-1) / (1 + a * z**-1)
def _cascade_freqz(a_list: NDArray, w: NDArray) -> NDArray:
"""オールパスフィルタを求める."""
H = np.ones_like(w, dtype=complex)
for a in a_list:
H *= _allpass_freqz(float(a), w)
return H
def _fit_allpass_pair(
fs: float, f1: float, f2: float, nsec: int, wpts: int = 512, init_ratio: float = np.sqrt(2.0)
) -> tuple[NDArray, NDArray]:
"""2つのオールパスフィルタを最適化する.
Args:
fs (float): サンプリング周波数
f1 (float): この範囲で最適化する (下限)
f2 (float): この範囲で最適化する (上限)
nsec (int): フィルタの係数の総数
wpts (int, optional): 周波数範囲内で最適化するサンプル数
init_ratio (float, optional): 初期値をずらす係数.
Returns:
tuple[NDArray, NDArray]: 2つのフィルタ係数
"""
# 初期値:帯域を等比配置、片方を少しシフト
f0 = np.geomspace(f1, f2, nsec)
f1s = np.clip(f0 * init_ratio, f1 * 1.05, f2 / 1.05)
a0 = np.array([_a_from_fc(fc, fs) for fc in f0], dtype=float)
a1 = np.array([_a_from_fc(fc, fs) for fc in f1s], dtype=float)
# 最適化準備
w = np.linspace(2 * np.pi * f1 / fs, 2 * np.pi * f2 / fs, wpts)
target = -(np.pi / 2) * np.ones_like(w)
x0 = np.concatenate([a0, a1])
# 最適化の目的関数
def resid(x):
a0x, a1x = x[:nsec], x[nsec:]
H0 = _cascade_freqz(a0x, w)
H1 = _cascade_freqz(a1x, w)
dphi = np.unwrap(np.angle(H1) - np.angle(H0))
return dphi - target
lb = -0.995 * np.ones_like(x0)
ub = +0.995 * np.ones_like(x0)
res = optimize.least_squares(resid, x0, bounds=(lb, ub), max_nfev=4000)
return res.x[:nsec].astype(float), res.x[nsec:].astype(float)
フィルタをかけるクラス
求まったフィルタから正の周波数, 負の周波数を得たい時に以下のような計算ができるクラスを作ります。
class OneSidedIIR:
"""IIR 片側通過フィルタ (正 or 負).
y_pos = 0.5*(H0.x + j.H1.x)
y_neg = 0.5*(H0.x - j.H1.x)
"""
def __init__(
self, fs: float, band: tuple[float, float], nsec: int = 6, mode: str = "pos",
design: str = "fit", wpts: int = 512) -> None:
self.fs = fs # サンプリング周波数
self.band = band # この範囲内でフィルタの最適化を行う
self.nsec = nsec # フィルタ数
self.mode = mode # "pos": 正周波数を抽出, "neg": 負周波数を抽出
self.design = design # "fit": 最適化する, "geometric": 最適化なし
self.wpts = wpts # 最適化の点数
# 初期化
self.set_mode(self.mode)
self.reset_state()
def _design_filters(self) -> None:
f1, f2 = self.band
if self.design == "fit":
self.a0, self.a1 = _fit_allpass_pair(self.fs, f1, f2, self.nsec, self.wpts)
elif self.design == "geometric":
f0 = np.geomspace(f1, f2, self.nsec)
f1s = np.clip(f0 * np.sqrt(2.0), f1 * 1.05, f2 / 1.05)
self.a0 = np.array([_a_from_fc(fc, self.fs) for fc in f0], dtype=float)
self.a1 = np.array([_a_from_fc(fc, self.fs) for fc in f1s], dtype=float)
else:
raise ValueError("design must be 'fit' or 'geometric'")
# 係数(b,a)を生成
self._ba0 = [(np.array([a, 1.0], float), np.array([1.0, a], float)) for a in self.a0]
self._ba1 = [(np.array([a, 1.0], float), np.array([1.0, a], float)) for a in self.a1]
def set_mode(self, mode: str) -> None:
mode = mode.lower()
if mode not in ("pos", "neg"):
raise ValueError("mode must be 'pos' or 'neg'")
self._sgn = +1.0 if mode == "pos" else -1.0
self.mode = mode
self._design_filters()
def reset_state(self) -> None:
self._zi0 = [np.zeros(1, dtype=complex) for _ in range(self.nsec)]
self._zi1 = [np.zeros(1, dtype=complex) for _ in range(self.nsec)]
def _cascade_lfilter(self, x: NDArray, ba_list, zi_list):
y = x
for i, (b, a) in enumerate(ba_list):
y, zi_list[i] = signal.lfilter(b, a, y, zi=zi_list[i])
return y
def process(self, x: NDArray) -> NDArray:
"""フィルタ処理"""
x = np.asarray(x).astype(complex, copy=False)
y0 = self._cascade_lfilter(x, self._ba0, self._zi0)
y1 = self._cascade_lfilter(x, self._ba1, self._zi1)
return 0.5 * (y0 + (1j * self._sgn) * y1)
実施例
具体例 で示した複素信号
# 信号作成
fs = 100.0
total_time = 10.
t = np.arange(int(total_time*fs)) / fs
x = 0.8*np.exp(1j*2*np.pi*1.5*t) + 0.5*np.exp(1j*2*np.pi*6.0*t) + 0.5*np.exp(-1j*2*np.pi*3.0*t)
x += 0.01*(np.random.default_rng(0).standard_normal(len(t)) + 1j*np.random.default_rng(1).standard_normal(len(t)))
# フィルタをかける (正の周波数)
flt = OneSidedIIR(fs, band=(0.3, 10.0), nsec=6, mode="pos", design="fit")
y_pos = flt.process(x)
# 負の周波数を抽出する
flt.set_mode("neg")
y_neg = flt.process(x)
周波数ごとの強度を求める関数は以下のように定義しました。
def spectrum(x, fs, nfft=8192):
X = np.fft.fftshift(np.fft.fft(x, nfft))
f = np.fft.fftshift(np.fft.fftfreq(nfft, d=1/fs))
mag = np.abs(X) + 1e-12
return f, 20*np.log10(mag/mag.max())
元の信号, 正の周波数のみになるようにフィルタをかけたもの, 負の周波数になるようにしたものは以下です。複素信号の実部を青色, 虚部をオレンジ色で表示しています。
正の周波数を抽出したものは実部が虚部に先行しており、
周波数解析の結果は以下です (再掲)。元信号と正の周波数のフィルタ通過後の結果です。負の周波数が大きく減衰していることが分かります。
参考文献
以下を参考にしました。
Discussion