anomaly-detection-material-parameters-calibration

Sionna param calibration (research proj)
git clone https://git.ea.contact/anomaly-detection-material-parameters-calibration
Log | Files | Refs | README

commit 6a8c8c9c5760ca6cffb5c0a3480dfc3415dedb7e
parent 7200e95666aacf692d4b0e87f0fab1f16249302d
Author: Egor Achkasov <eaachkasov@gmail.com>
Date:   Thu, 16 Oct 2025 13:22:12 +0200

mv load_data.py measurements.py; Improve tf solution; Fix np solution measurements reshape

Diffstat:
MREADME.md | 4++--
Dsrc/load_data.py | 174-------------------------------------------------------------------------------
Asrc/measurements.py | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/np_paths.py | 22+++++++++++-----------
Msrc/tf_paths.py | 536+++++++++++++++++++++++++++++++++++++------------------------------------------
Dsrc/trace_paths.py | 23-----------------------
6 files changed, 362 insertions(+), 497 deletions(-)

diff --git a/README.md b/README.md @@ -18,9 +18,9 @@ Find the eact parameters in the code (`src/??_paths.py`). Unfortunately, the mea # Repository structure - `scene`: **Simplified** version of the [scene](https://github.com/akdd11/anomaly-detection-calibrated-raytracing/tree/main/scenes/scene0). -- `src/load_data.py`: loader function for the measurements dataset. +- `src/measurements.py`: loader function for the measurements dataset. - `src/np_paths.py`: numpy-based implementation of the method. Note that the gradient is calculated naively, what is a serious performance bottleneck. This solution can be used for testing, but strugles with production of any results in a reasonable time with bigger parameter domain size. -- `src/tf_paths.py`: same, but the gradient is calculated using Tensorflow's tape, is in the [NVidia material calibration notebook](https://github.com/NVlabs/diff-rt-calibration/blob/main/notebooks/Learned_Materials.ipynb). Slightly more convoluted than the numpy version and a pain to debug. +- `src/tf_paths.py`: same, but the gradient is calculated using Tensorflow's tape, is in the [NVidia material calibration notebook](https://github.com/NVlabs/diff-rt-calibration/blob/main/notebooks/Learned_Materials.ipynb). Slightly more convoluted than the numpy version and a pain to debug. Currently the graph mode is broken leading to the gradient not being calculated. Finding a way to fix it would solve the np solution's performance issue. - `src/v.py`: visualization script. Described below. # Algorithm diff --git a/src/load_data.py b/src/load_data.py @@ -1,174 +0,0 @@ -""" -This file contains an implementation of a data loader for -the measurement data used from doi: 10.1109/JCS64661.2025.10880629 - -The data directory should contain files named in the format: -"Round_<round_idx>_AP_<ap_idx>_RF_<rf_idx>_Sec_<sec_idx>.mat" -where <round_idx>, <ap_idx>, <rf_idx>, and <sec_idx> are integers. -The <round_idx> is the round index, <ap_idx> is the access point index, -<rf_idx> is the RF index, and <sec_idx> is the second index. - -Each file must be loadable with scipy.io.loadmat -and must contain a field named 'cirs' with the shape (512, 1000) each. -""" - -import numpy as np -from scipy.io import loadmat as scipy_io_loadmat - -from typing import Tuple, Dict -from os import listdir as os_listdir -from os.path import join as os_path_join -from functools import reduce as functools_reduce -from functools import partial as functools_partial -from itertools import product as itertools_product -from multiprocessing import Pool as multiprocessing_Pool - - -def load_measurements( - data_dirpath: str, - load_only_round: int | None = None - ) -> Tuple[ - np.ndarray, - Dict[int, int], - Dict[int, int], - Dict[int, int], - Dict[int, int] - ]: - """Load the measurements from the given data directory. - Args: - data_dirpath (str): The path to the directory containing the files. - Returns: - Tuple containing: - - cirs_truth (np.ndarray): The channel impulse responses. - Shape: (num_rounds, num_aps, num_rfs, num_secs, 512, 1000) - - round_idx_map (dict): Mapping from round to array indices. - - ap_idx_map (dict): Mapping from access point to array indices. - - rf_idx_map (dict): Mapping from RF to array indices. - - sec_idx_map (dict): Mapping from second to array indices. - Note: - - The measurements filenames are expected to be in the format: - "Round_<round_idx>_AP_<ap_idx>_RF_<rf_idx>_Sec_<sec_idx>.mat" - - In case of missing seconds for some rounds, aps, or rfs, - inner join is performed on the available seconds. - Example: round 1 has seconds 0, 1, 2, 3, - round 2 has seconds 1, 2, 3, 4, then - the available seconds for both rounds will be 1, 2, 3. - - Example usage of the maps: - ```python - round_idx = 1 - ap_idx = 2 - rf_idx = 0 - sec_idx = 3 - array_idx = ( - round_idx_map[round_idx], - ap_idx_map[ap_idx], - rf_idx_map[rf_idx], - sec_idx_map[sec_idx] - ) - cfr = cfrs[array_idx] - ``` - """ - - # Get the rounds, aps, rfs, secs from the filenames - rounds = set() - aps = set() - rfs = set() - secs = {} - for filename in os_listdir(data_dirpath): - if not filename.endswith(".mat") and not filename.startswith("Round_"): - continue - s = filename[6:-4].split("_") - if len(s) != 7: - continue - round_idx, ap_idx, rf_idx, sec_idx = map( - int, - (s[0], s[2], s[4], s[6]) - ) - if load_only_round is not None and round_idx != load_only_round: - continue - rounds.add(round_idx) - aps.add(ap_idx) - rfs.add(rf_idx) - secs[(round_idx, ap_idx, rf_idx)] = secs.get( - (round_idx, ap_idx, rf_idx), set()) - secs[(round_idx, ap_idx, rf_idx)].add(sec_idx) - rounds = sorted(rounds) - aps = sorted(aps) - rfs = sorted(rfs) - # Inner join of all available seconds - secs = sorted(functools_reduce( - set.intersection, - (s for s in secs.values()) - )) - - # Get num_rounds, num_aps, num_rfs, num_seconds - num_rounds = len(rounds) - num_aps = len(aps) - num_rfs = len(rfs) - num_secs = len(secs) - - # Make indexes maps for rounds, aps, rfs, secs - round_idx_map = {r: i for i, r in enumerate(rounds)} - ap_idx_map = {ap: i for i, ap in enumerate(aps)} - rf_idx_map = {rf: i for i, rf in enumerate(rfs)} - sec_idx_map = {sec: i for i, sec in enumerate(secs)} - - # Load the measurements - with multiprocessing_Pool() as pool: - func = functools_partial( - load_and_store, - data_dirpath=data_dirpath, - round_idx_map=round_idx_map, - ap_idx_map=ap_idx_map, - rf_idx_map=rf_idx_map, - sec_idx_map=sec_idx_map - ) - results = pool.map( - func, - itertools_product(rounds, aps, rfs, secs) - ) - cirs = np.empty( - (num_rounds, num_aps, num_rfs, num_secs, 512, 1000), - dtype=np.complex128 - ) - for i, j, k, l, cir in results: - cirs[i, j, k, l, :] = cir - - # cirs = np.empty( - # (num_rounds, num_aps, num_rfs, num_secs, 512, 1000), - # dtype=np.complex128 - # ) - # for r, ap, rf, sec in itertools_product(rounds, aps, rfs, secs): - # filename = f"Round_{r}_AP_{ap}_RF_{rf}_Sec_{sec}.mat" - # filepath = os_path_join(data_dirpath, filename) - # cir = scipy_io_loadmat(filepath)['cirs'] - # assert cir.shape == (512, 1000) - # cirs[ - # round_idx_map[r], - # ap_idx_map[ap], - # rf_idx_map[rf], - # sec_idx_map[sec], - # : - # ] = cir - - return cirs, round_idx_map, ap_idx_map, rf_idx_map, sec_idx_map - - -def load_and_store( - args, - data_dirpath, round_idx_map, ap_idx_map, rf_idx_map, sec_idx_map -): - r, ap, rf, sec = args - filepath = os_path_join( - data_dirpath, - f"Round_{r}_AP_{ap}_RF_{rf}_Sec_{sec}.mat" - ) - cir = scipy_io_loadmat(filepath)['cirs'] - assert cir.shape == (512, 1000) - return ( - round_idx_map[r], - ap_idx_map[ap], - rf_idx_map[rf], - sec_idx_map[sec], - cir - ) diff --git a/src/measurements.py b/src/measurements.py @@ -0,0 +1,100 @@ +""" +This file contains an implementation of a data loader for +the measurement data used from doi: 10.1109/JCS64661.2025.10880629 + +The data directory should contain files named in the format: +"Round_<round_idx>_AP_<ap_idx>_RF_<rf_idx>_Sec_<sec_idx>.mat" +where <round_idx>, <ap_idx>, <rf_idx>, and <sec_idx> are integers. +The <round_idx> is the round index, <ap_idx> is the access point index, +<rf_idx> is the RF index, and <sec_idx> is the second index. + +Each file must be loadable with scipy.io.loadmat +and must contain a field named 'cirs' with the shape (512, 1000) each. +""" + +import numpy as np +from scipy.io import loadmat as scipy_io_loadmat + +from os import listdir as os_listdir +from os.path import join as os_path_join +from functools import reduce as functools_reduce +from functools import partial as functools_partial +from itertools import product as itertools_product +from multiprocessing import Pool as multiprocessing_Pool + + +def load( + data_dirpath: str, + round_idx: int +) -> np.ndarray: + """Load the measurements round from the given data directory. + Args: + data_dirpath (str): The path to the directory containing the files. + round (int): The round index. + Returns: + cirs_truth (np.ndarray): + The channel impulse responses. + Shape: (num_aps, num_rfs, num_secs, 512, 1000) + Note: + - The measurements filenames are expected to be in the format: + "Round_<round_idx>_AP_<ap_idx>_RF_<rf_idx>_Sec_<sec_idx>.mat" + """ + + # Get the rounds, aps, rfs, secs from the filenames + aps = set() + rfs = set() + secs = {} + for filename in os_listdir(data_dirpath): + if not filename.endswith(".mat") and not filename.startswith("Round_"): + continue + s = filename[6:-4].split("_") + if len(s) != 7: + continue + if int(s[0]) != round_idx: + continue + ap_idx, rf_idx, sec_idx = map(int, (s[2], s[4], s[6])) + aps.add(ap_idx) + rfs.add(rf_idx) + secs[(ap_idx, rf_idx)] = secs.get((ap_idx, rf_idx), set()) + secs[(ap_idx, rf_idx)].add(sec_idx) + # Inner join of all available seconds + secs = sorted(functools_reduce( + set.intersection, + (s for s in secs.values()) + )) + + # Get num_aps, num_rfs, num_seconds + num_aps = len(aps) + num_rfs = len(rfs) + num_secs = len(secs) + + # Load the measurements + with multiprocessing_Pool() as pool: + func = functools_partial( + load_and_store, + data_dirpath=data_dirpath, + round_idx=round_idx + ) + results = pool.map( + func, + itertools_product(aps, rfs, secs) + ) + cirs = np.empty( + (num_aps, num_rfs, num_secs, 512, 1000), + dtype=np.complex128 + ) + for j, k, l, cir in results: + cirs[j, k, l, :] = cir + + return cirs + + +def load_and_store(args, data_dirpath, round_idx): + ap, rf, sec = args + filepath = os_path_join( + data_dirpath, + f"Round_{round_idx}_AP_{ap}_RF_{rf}_Sec_{sec}.mat" + ) + cir = scipy_io_loadmat(filepath)['cirs'] + assert cir.shape == (512, 1000) + return ap-1, rf, sec-1, cir diff --git a/src/np_paths.py b/src/np_paths.py @@ -12,7 +12,7 @@ # - Trains the scene material parameters according to Algorithm 1 in the paper # - Saves the losses and the trainable parameters (omega) to files (losses.npy and omegas.npy) -from load_data import load_measurements +from measurements import load as measurements_load import numpy as np import sionna.rt as srt @@ -233,6 +233,7 @@ if __name__ == "__main__": g_scene = srt.load_scene( os.path.join( os.path.dirname(__file__), + "..", "scene", "scene.xml") ) @@ -295,18 +296,17 @@ if __name__ == "__main__": print("Loading measurements...") data_dirpath = os.path.join( os.path.dirname(__file__), - "..", "data" + "..", "..", "data" ) - g_cirs = load_measurements(data_dirpath, g_cfg_load_only_round)[0] + g_cirs = measurements_load(data_dirpath, g_cfg_load_only_round) g_bandwidth = g_cirs.shape[-2] * g_cfg_subcarrier_spacing # W in the paper - g_cirs = g_cirs[0].reshape( - (g_cirs.shape[1], - g_cirs.shape[2], - g_cirs.shape[4], - g_cirs.shape[3] * g_cirs.shape[5],) - ) # Reshape to (num_aps, num_rfs, num_subcarriers, num_samples) - # Transpose to (num_samples, num_aps, num_rfs, num_subcarriers) - g_cirs = np.transpose(g_cirs, (3, 0, 1, 2)) + g_cirs = g_cirs.transpose((2, 4, 0, 1, 3)) + g_cirs = g_cirs.reshape(( + g_cirs.shape[0] * g_cirs.shape[1], + g_cirs.shape[2], + g_cirs.shape[3], + g_cirs.shape[4] + )) # Reshape to (num_seconds * 1000, num_aps, num_rfs, num_subcarriers) # Trace paths g_paths_buffers = [] # Initialize the paths buffers list diff --git a/src/tf_paths.py b/src/tf_paths.py @@ -12,26 +12,25 @@ # - Trains the scene material parameters according to Algorithm 1 in the paper # - Saves the losses and the trainable parameters (omega) to files (losses.npy and omegas.npy) -from load_data import load_measurements +from measurements import load as measurements_load import numpy as np -import sionna.rt as srt import tensorflow as tf +import sionna.rt as srt +from sionna.constants import PI from typing import Tuple, List -from sys import stdout import os # Config # Dataset -g_cfg_load_only_round = 11 # Set to None to load all rounds +g_cfg_meas_round = 11 # Measurements round to load g_cfg_num_samples = 10 # Number of samples to use from the dataset. Picked randomly. g_cfg_batch_size = 3 # Number of samples in each batch (B) # Fit -g_cfg_seed = 42 # Random seed for reproducibility g_cfg_num_embeddings = 2 # L in the paper g_cfg_learning_rate = 0.01 # beta in the paper -g_cfg_decay = 0.001 # delta in the paper +g_cfg_decay = tf.Variable(0.001, dtype=tf.float32) # delta in the paper g_cfg_max_iterations = 5 # max number of iterations for the optimization g_cfg_convergence_threshold = 1e-6 g_cfg_eps = 1e-6 # For numerical gradient calculation @@ -50,11 +49,11 @@ g_cfg_rx_locations = [ [22, 0.5, 2.85] ] g_cfg_rx_orientations = [ - [0, 0, -np.pi/2], + [0, 0, -PI/2], [0, 0, 0], - [0, 0, -np.pi/2], + [0, 0, -PI/2], [0, 0, 0], - [0, 0, np.pi/2], + [0, 0, PI/2], [0, 0, 0] ] g_cfg_tx_position_initial = np.array([5.5, 5, 0.5]) # Initial TX position @@ -73,32 +72,16 @@ g_cfg_record_omegas = True # Globals g_scene: srt.Scene -g_losses: List[float] # Losses history per iteration. Saved as a numpy array. -g_omega: np.ndarray # Shape (2 * 4 * num_materials * num_embeddings) (2 - v/w, 4 - cond/perm/scat/xpd) - # Note that the array is flattened, - # so the first 4*num_materials*num_embeddings elements are v, and the rest are w - # Before saving, it is reshaped to (2, 4, num_materials, num_embeddings) -g_cirs: np.ndarray # The dataset of channel impulse responses (CIRs) loaded from the measurements. - # Shape (num_samples, num_aps, num_rfs, num_subcarriers) g_trainable_materials: List[srt.RadioMaterial] = [] # List of trainable materials in the scene -g_paths_buffers: List[Tuple] # List of g_scene.trace_paths outputs for each sample, - # Each element is a tuple of 8: 4 srt.Paths and 4 srt.PathsTmpData - # Use each entry as: g_scene.compute_fields(*elem, ...) - # (length g_num_samples) -g_indices: np.ndarray # Indices of the samples in the g_paths_buffers. Shape (g_num_samples,) -g_tx_poss: List[np.ndarray] # List of TX positions for each sample (length g_num_samples) g_bandwidth: float # Bandwidth of the signal (W in the paper) -def calc_material_parameters( - w: np.ndarray, - v: np.ndarray, -) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: +@tf.function +def calc_material_parameters(omega: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor]: """Calculate the material parameters from the read-out vectors and embeddings. Args: - w: The embeddings (shape: (4, num_materials, num_embeddings)). - v: The read-out vectors (shape: (4, num_materials, num_embeddings)). + omega: Read-out and embeddings tensor. Shape (2, 4, num_materials, num_embeddings) Returns: Tuple containing: @@ -107,24 +90,21 @@ def calc_material_parameters( - scat: Scattering coefficient of the materials. - xpd: XPD coefficient of the materials. """ - dot = np.sum(v * w, axis=-1) - cond = np.exp(dot[0]) - perm = np.exp(dot[1]) + 1. - - def sigmoid(x: np.ndarray) -> np.ndarray: - return 1 / (1 + np.exp(-x)) - scat = sigmoid(dot[2]) - xpd = sigmoid(dot[3]) - + dot = tf.reduce_sum(omega[0] * omega[1], axis=-1) + cond = tf.exp(dot[0]) + perm = tf.exp(dot[1]) + 1. + scat = tf.sigmoid(dot[2]) + xpd = tf.sigmoid(dot[3]) return cond, perm, scat, xpd +@tf.function def calc_omega( - cond: np.ndarray, - perm: np.ndarray, - scat: np.ndarray, - xpd: np.ndarray, -) -> np.ndarray: + cond: tf.Tensor, + perm: tf.Tensor, + scat: tf.Tensor, + xpd: tf.Tensor, +) -> tf.Tensor: """Calculate the omega vector from the material parameters. Args: @@ -134,271 +114,253 @@ def calc_omega( xpd: XPD coefficient of the materials. Returns: - The omega vector (shape: (2 * 4 * num_materials * num_embeddings,)). + The omega vector (shape: (2, 4, num_materials, num_embeddings,)). """ - num_materials = len(cond) - - dot = np.empty((4, num_materials), dtype=np.float32) - dot[0] = np.log(cond) - dot[1] = np.log(perm - 1.) - - def invert_sigmoid(x: np.ndarray) -> np.ndarray: - return -np.log(1 / x - 1) - dot[2] = invert_sigmoid(scat) - dot[3] = invert_sigmoid(xpd) - - w = np.random.uniform(-1, 1, (4, num_materials, g_cfg_num_embeddings)).astype(np.float32) - v = dot[:, :, np.newaxis] / w - - - return np.concatenate([v.flatten(), w.flatten()]) - - -def set_omega() -> None: + num_materials = tf.shape(cond)[0] + dot0 = tf.math.log(cond) + dot1 = tf.math.log(perm - 1.) + dot2 = -tf.math.log(1 / scat - 1) + dot3 = -tf.math.log(1 / xpd - 1) + dot = tf.stack([dot0, dot1, dot2, dot3], axis=0) + w = tf.random.uniform((4, num_materials, g_cfg_num_embeddings), minval=-1, maxval=1, dtype=tf.float32) + v = dot[:, :, tf.newaxis] / w + return tf.stack([v, w], axis=0) + + +@tf.function +def set_omega(omega: tf.Tensor) -> None: """Set the trainable parameters in the scene from omega.""" - v = g_omega[:4 * len(g_trainable_materials) * g_cfg_num_embeddings].reshape( - (4, len(g_trainable_materials), g_cfg_num_embeddings) - ) - w = g_omega[4 * len(g_trainable_materials) * g_cfg_num_embeddings:].reshape( - (4, len(g_trainable_materials), g_cfg_num_embeddings) - ) - cond, perm, scat, xpd = calc_material_parameters(w, v) + cond, perm, scat, xpd = calc_material_parameters(omega) for idx, mat in enumerate(g_trainable_materials): - g_scene._radio_materials[mat.name].conductivity = cond[idx] - g_scene._radio_materials[mat.name].relative_permittivity = perm[idx] - g_scene._radio_materials[mat.name].scattering_coefficient = scat[idx] - g_scene._radio_materials[mat.name].xpd_coefficient = xpd[idx] + g_scene._radio_materials[mat.name].conductivity = tf.gather(cond, idx) + g_scene._radio_materials[mat.name].relative_permittivity = tf.gather(perm, idx) + g_scene._radio_materials[mat.name].scattering_coefficient = tf.gather(scat, idx) + g_scene._radio_materials[mat.name].xpd_coefficient = tf.gather(xpd, idx) -g_p_hat = np.empty((g_cfg_batch_size,), dtype=np.float32) -g_tau_hat = np.empty((g_cfg_batch_size,), dtype=np.float32) -g_alpha = 6e-9 # Initial α_i -def objective(batch_indices) -> float: +g_alpha = tf.Variable(6e-9, dtype=tf.float32) # Initial α_i +@tf.function +def objective(cirs_batch, batch_indices, omega, paths_buffers) -> tf.Variable: """Compute the objective function for the optimization. Returns the average loss over the batch as in (38) of the paper. """ - set_omega() - - # Compute predicted CIR - for i, ind in enumerate(batch_indices): - a, tau = g_scene.compute_fields( - *g_paths_buffers[ind], - check_scene=False, - scat_random_phases=False, - testing=False, - ).cir( - los=g_cfg_los, - reflection=g_cfg_specular_reflection, - diffraction=g_cfg_refraction, - scattering=g_cfg_diffuse_reflection, - ris=False, - cluster_ris_paths=False, - num_paths=None, + set_omega(omega) + p_hat = tf.TensorArray(dtype=tf.float32, size=tf.shape(batch_indices)[0]) + tau_hat = tf.TensorArray(dtype=tf.float32, size=tf.shape(batch_indices)[0]) + for i in tf.range(tf.shape(batch_indices)[0]): + ind = batch_indices[i] + def get_a_tau(index): + return g_scene.compute_fields( + *paths_buffers[int(index)], + check_scene=False, + scat_random_phases=False, + testing=False, + ).cir( + los=g_cfg_los, + reflection=g_cfg_specular_reflection, + diffraction=g_cfg_refraction, + scattering=g_cfg_diffuse_reflection, + ris=False, + cluster_ris_paths=False, + num_paths=None, + ) + a, tau = tf.py_function( + get_a_tau, + [ind], + Tout=[tf.complex64, tf.float32] ) - a = a.numpy() - tau = tau.numpy() - a = a[~np.isnan(a)] - tau = tau[tau != -1] - g_p_hat[i] = np.sum(np.abs(a) ** 2) - g_tau_hat[i] = np.mean(tau) + # Record + p_val = tf.reduce_sum(tf.abs(a) ** 2) + tau_val = tf.reduce_mean(tau) + p_hat = p_hat.write(i, p_val) + tau_hat = tau_hat.write(i, tau_val) + p_hat = p_hat.stack() + tau_hat = tau_hat.stack() # Scale the batch: h_b ← √α_i * h_b - batch = g_cirs[batch_indices] - p = np.sum(np.abs(batch) ** 2, axis=(0, 1, 2)) - alpha_hat = np.sum(p[:, None] * g_p_hat) / (np.sum(g_p_hat**2) + 1e-8) - alpha = float(g_cfg_decay * g_alpha + (1 - g_cfg_decay) * alpha_hat) - batch *= np.sqrt(alpha) - p = np.sum(np.abs(batch) ** 2, axis=(0, 1, 2)) + p = tf.reduce_sum(tf.abs(cirs_batch) ** 2, axis=(0, 1, 2)) + alpha_hat = tf.reduce_sum(p[..., tf.newaxis] * p_hat) / (tf.reduce_sum(p_hat ** 2) + 1e-8) + alpha = g_cfg_decay * g_alpha + (1 - g_cfg_decay) * alpha_hat + alpha = tf.cast(alpha, tf.complex64) + cirs_scaled = tf.sqrt(alpha) * cirs_batch + p = tf.reduce_sum(tf.abs(cirs_scaled) ** 2, axis=(0, 1, 2)) # Approximate tau_RMS as in (40, 41) - total_channel_gain = np.sum(p) - p_scaled = p / (total_channel_gain + 1e-8) - ls = np.arange(batch.shape[2]) - batch.shape[2] // 2 # Subcarrier indices - tau_overline = np.sum(ls[:, None] * p_scaled) + total_channel_gain = tf.reduce_sum(p) + p = p / (total_channel_gain + 1e-8) + ls = np.arange(512) - 512 // 2 # Subcarrier indices + tau_overline = tf.reduce_sum(ls[:, None] * p, axis=0) tau_rms = (ls - tau_overline) / g_bandwidth - tau_rms = np.sqrt(np.sum((tau_rms ** 2)[:, None] * p_scaled)) + tau_rms = tf.sqrt(tf.reduce_sum((tau_rms ** 2)[:, None] * p)) # Compute sample loss L_b as in (38) def smape(x, y): - return np.abs(x - y) / (np.abs(x) + np.abs(y) + 1e-8) - losses = np.mean(smape(p[:, None], g_p_hat), axis=0) + smape(tau_rms, g_tau_hat) - + return tf.abs(x - y) / (tf.abs(x) + tf.abs(y) + 1e-8) + losses = tf.reduce_mean(smape(p[..., tf.newaxis], p_hat), axis=0) + smape(tau_rms, tau_hat) # Average loss over the batch: L = (1/B) * sum(L_b) - return float(np.mean(losses)) - - -################################################################################ -# Scene loading and setup -################################################################################ - -print("Loading scene...") -g_scene = srt.load_scene( - os.path.join( - os.path.dirname(__file__), - "scene", - "scene.xml") -) -g_scene.frequency = g_cfg_central_frequency -g_scene.tx_array = srt.PlanarArray( - num_rows=1, - num_cols=1, - vertical_spacing=0.5, - horizontal_spacing=0.5, - pattern="dipole", - polarization="V", -) -g_scene.rx_array = srt.PlanarArray( - num_rows=1, - num_cols=1, - vertical_spacing=0.5, - horizontal_spacing=0.5, - pattern="dipole", - polarization="VH", -) -for rx_idx, rx_loc in enumerate(g_cfg_rx_locations): - rx = srt.Receiver( - name=f"rx{rx_idx}", - position=rx_loc, - orientation=g_cfg_rx_orientations[rx_idx], + return tf.reduce_mean(losses) + + +if __name__ == "__main__": + # Initialization + # Scene + print("Loading scene... ", end='', flush=True) + g_scene = srt.load_scene( + os.path.join( + os.path.dirname(__file__), + "..", + "scene", + "scene.xml") ) - g_scene.add(rx) -tx = srt.Transmitter( - name="tx", - position=g_cfg_tx_position_initial, -) -g_scene.add(tx) - -# Make trainable materials -for mat in g_scene.radio_materials.values(): - if not mat.is_used: - continue - # Create new trainable material - if g_cfg_keep_material_params: - # Start from the current material parameters - new_mat = srt.RadioMaterial( - mat.name + "_train", - relative_permittivity=mat.relative_permittivity, - conductivity=mat.conductivity, - scattering_coefficient=mat.scattering_coefficient, - xpd_coefficient=mat.xpd_coefficient + g_scene.frequency = g_cfg_central_frequency + g_scene.tx_array = srt.PlanarArray( + num_rows=1, + num_cols=1, + vertical_spacing=0.5, + horizontal_spacing=0.5, + pattern="dipole", + polarization="V", + ) + g_scene.rx_array = srt.PlanarArray( + num_rows=1, + num_cols=1, + vertical_spacing=0.5, + horizontal_spacing=0.5, + pattern="dipole", + polarization="VH", + ) + for rx_idx, rx_loc in enumerate(g_cfg_rx_locations): + rx = srt.Receiver( + name=f"rx{rx_idx}", + position=rx_loc, + orientation=g_cfg_rx_orientations[rx_idx], ) - else: - new_mat = srt.RadioMaterial(mat.name + "_train", - relative_permittivity=3.0, - conductivity=0.1) - g_scene.add(new_mat) - g_trainable_materials.append(new_mat) - -# Assign trainable materials to the corresponding objects -for obj in g_scene.objects.values(): - obj.radio_material = obj.radio_material.name + "_train" - -################################################################################ -# Load the measurements -################################################################################ - -print("Loading measurements...") -data_dirpath = os.path.join( - os.path.dirname(__file__), - "..", "data" -) -g_cirs = load_measurements(data_dirpath, g_cfg_load_only_round)[0] -g_bandwidth = g_cirs.shape[-2] * g_cfg_subcarrier_spacing # W in the paper -g_cirs = g_cirs[0].reshape( - (g_cirs.shape[1], - g_cirs.shape[2], - g_cirs.shape[4], - g_cirs.shape[3] * g_cirs.shape[5],) -) # Reshape to (num_aps, num_rfs, num_subcarriers, num_samples) -# Transpose to (num_samples, num_aps, num_rfs, num_subcarriers) -g_cirs = np.transpose(g_cirs, (3, 0, 1, 2)) - -################################################################################ -# Trace paths -################################################################################ - -g_paths = [] # Initialize the paths buffers list -g_indices = np.random.randint(g_cirs.shape[0], size=g_cfg_num_samples) -for j, i in enumerate(g_indices): # For each sample - stdout.write(f"\rTracing paths for sample {j}/{g_cfg_num_samples} ({i})...") - stdout.flush() - - tx_pos = g_cfg_tx_position_initial + i * g_cfg_tx_velocity - g_scene._transmitters['tx'].position = tx_pos - - g_paths.append(g_scene.trace_paths( - max_depth=g_cfg_max_depth, - num_samples=g_cfg_trace_paths_num_samples, - los=g_cfg_los, - reflection=g_cfg_specular_reflection, - diffraction=g_cfg_refraction, - scattering=g_cfg_diffuse_reflection, - ris=False, - edge_diffraction=False, - check_scene=False, - )) -print() -g_cirs = g_cirs[g_indices] - -# Fit the scene -prev_loss = np.inf # Initialize previous loss for convergence check -omega_size = 2 * 4 * len(g_trainable_materials) * g_cfg_num_embeddings -optimizer = tf.keras.optimizers.Adam(learning_rate=g_cfg_learning_rate) - -loss_history = [] -omega_history = [] -for i in range(g_cfg_max_iterations): - print(f"Iteration {i + 1}/{g_cfg_max_iterations}...") - - # Draw a random batch - batch_indices = np.random.choice( - g_cfg_num_samples, - g_cfg_batch_size, - replace=False, + g_scene.add(rx) + tx = srt.Transmitter( + name="tx", + position=g_cfg_tx_position_initial, + ) + g_scene.add(tx) + print("Done") + + # Make trainable materials + for mat in g_scene.radio_materials.values(): + if not mat.is_used: + continue + # Create new trainable material + if g_cfg_keep_material_params: + # Start from the current material parameters + new_mat = srt.RadioMaterial( + mat.name + "_train", + relative_permittivity=mat.relative_permittivity, + conductivity=mat.conductivity, + scattering_coefficient=mat.scattering_coefficient, + xpd_coefficient=mat.xpd_coefficient + ) + else: + new_mat = srt.RadioMaterial(mat.name + "_train", + relative_permittivity=3.0, + conductivity=0.1) + g_scene.add(new_mat) + g_trainable_materials.append(new_mat) + + # Assign trainable materials to the corresponding objects + for obj in g_scene.objects.values(): + obj.radio_material = obj.radio_material.name + "_train" + + # Load the measurements + print("Loading measurements... ", end='', flush=True) + data_dirpath = os.path.join( + os.path.dirname(__file__), + "..", "..", "data" + ) + # [num_aps, num_rfs, num_seconds, num_subcarriers, 1000] + cirs = measurements_load(data_dirpath, g_cfg_meas_round) + # Cast to complex64 + cirs = cirs.astype(np.complex64) + # Move num_seconds to 0 and 1000 to 1 + # [num_seconds, 1000, num_aps, num_rfs, num_subcarriers] + cirs = cirs.transpose((2, 4, 0, 1, 3)) + # Unite num_seconds and 1000 + # [num_seconds * 1000, num_aps, num_rfs, num_subcarriers] + cirs = cirs.reshape((cirs.shape[0] * cirs.shape[1], cirs.shape[2], cirs.shape[3], cirs.shape[4])) + g_bandwidth = cirs.shape[-2] * g_cfg_subcarrier_spacing # W in the paper + print("Done") + + # Make the dataset + # Contains: + # - CIRs (shape (num_samples, num_aps, num_rfs, num_subcarriers)) + # - paths indices (shape (num_samples,)) + indices = np.random.randint(0, cirs.shape[0], (g_cfg_num_samples,)) + dataset = tf.data.Dataset.from_tensor_slices((cirs[indices], np.arange(g_cfg_num_samples))) + assert dataset.cardinality() == g_cfg_num_samples + + # Trace paths + print("Tracing paths... ", end="", flush=True) + paths_buffers = [] # Initialize the paths buffers list + tx_poss = g_cfg_tx_position_initial + indices[:, np.newaxis] * g_cfg_tx_velocity + for tx_pos in tx_poss: + g_scene._transmitters['tx'].position = tx_pos + paths_buffers.append(g_scene.trace_paths( + max_depth=g_cfg_max_depth, + num_samples=g_cfg_trace_paths_num_samples, + los=g_cfg_los, + reflection=g_cfg_specular_reflection, + diffraction=g_cfg_refraction, + scattering=g_cfg_diffuse_reflection, + ris=False, + edge_diffraction=False, + check_scene=False, + )) + print("Done") + + # Fit the scene + prev_loss = tf.Variable(1e10, dtype=tf.float32) + omega = tf.random.uniform( + (2, 4, len(g_trainable_materials), g_cfg_num_embeddings), + minval=-1, maxval=1, dtype=tf.float32 + ) + omega = tf.Variable(omega, dtype=tf.float32) + optimizer = tf.keras.optimizers.Adam( + learning_rate=g_cfg_learning_rate, + # beta_1=0.9, + # beta_2=0.999, + # epsilon=1e-8, ) - # Loss - with tf.GradientTape() as tape: - curr_loss = objective(batch_indices) - print(f"Current loss: {curr_loss:.6f}") - - # Adam optimization step - optimizer.apply_gradients(zip( - tape.gradient( - curr_loss, - tape.watched_variables, - unconnected_gradients=tf.UnconnectedGradients.ZERO - ), - tape.watched_variables() # type: ignore - )) - - # Update the scene parameters with the new omega - set_omega() - - # Record the loss and omega + print("Starting optimization...", flush=True) + loss_history = [] + omega_history = [] + dataset = dataset.batch(g_cfg_batch_size) + for cirs_batch, paths_indices_batch in dataset: + with tf.GradientTape() as tape: + curr_loss = objective(cirs_batch, paths_indices_batch, omega, paths_buffers) + grad = tape.gradient(curr_loss, omega) + optimizer.apply_gradients(zip([grad], [omega])) + + if g_cfg_record_loss: + loss_history.append(curr_loss) + if g_cfg_record_omegas: + omega_history.append(omega.numpy().copy()) + + # Check convergence: Stop if change in loss is below threshold + if abs(prev_loss - curr_loss) < g_cfg_convergence_threshold: + print("Converged") + break + prev_loss = curr_loss + print(f"Loss: {curr_loss}", flush=True) + + print("Training completed") + + # Save the losses and omegas + losses = np.asarray(loss_history) + omegas = np.asarray(omega_history) if g_cfg_record_loss: - loss_history.append(curr_loss) - - # Check convergence: Stop if change in loss is below threshold - if abs(prev_loss - curr_loss) < g_cfg_convergence_threshold: - break - prev_loss = curr_loss - - print(f"Iteration {i}, Loss: {curr_loss:.6f}") - -# Final update to scene parameters -set_omega() -print("Training completed") - -# Save the losses and omegas -losses = np.asarray(loss_history) -omegas = np.asarray(omega_history) -if g_cfg_record_loss: - np.save("losses.npy", losses) - print("Saved losses to losses.npy: ", losses.shape) -if g_cfg_record_omegas: - omegas = omegas.reshape( - (-1, 2, 4, len(g_trainable_materials), g_cfg_num_embeddings) - ) - np.save("omegas.npy", omegas) - print("Saved omegas to omegas.npy: ", omegas.shape) + np.save("losses.npy", losses) + print("Saved losses to losses.npy: ", losses.shape) + if g_cfg_record_omegas: + omegas = omegas.reshape( + (-1, 2, 4, len(g_trainable_materials), g_cfg_num_embeddings) + ) + np.save("omegas.npy", omegas) + print("Saved omegas to omegas.npy: ", omegas.shape) diff --git a/src/trace_paths.py b/src/trace_paths.py @@ -1,23 +0,0 @@ -# flake8: noqa: E501 - -# provides trace_paths function - -from typing import List - -import numpy as np -import sionna.rt as srt - - -def trace_paths( - scene: srt.Scene, - tx_poss: np.ndarray, - trace_paths_params: dict, -) -> List[srt.Paths]: - """Trace paths for given transmitter positions in a scene. - The scene must contain at least one transmitter, named "tx". - """ - paths = [] - for tx_pos in tx_poss: - scene._transmitters["tx"].position = tx_pos # type: ignore[attr-defined] - paths.append(scene.trace_paths(**trace_paths_params)) - return paths