From c9949677298603366908a6a17155defed7abda1b Mon Sep 17 00:00:00 2001 From: CALVO GONZALEZ Ramon Date: Tue, 25 Mar 2025 15:10:49 +0100 Subject: [PATCH] feat: a baby is born? --- pyproject.toml | 2 + src/ddpm/generate_circle_dataset.py | 142 +++++++++++++--- src/ddpm/sample.py | 255 ++++++++++++++++++++++++---- src/ddpm/train.py | 87 ++++++---- uv.lock | 108 ++++++++++++ 5 files changed, 509 insertions(+), 85 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e3f5dbd..d277127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,5 +11,7 @@ dependencies = [ "tqdm>=4.66.5", "numpy>=2.1.2", "pyqt6>=6.7.1", + "scipy>=1.15.2", + "seaborn>=0.13.2", ] diff --git a/src/ddpm/generate_circle_dataset.py b/src/ddpm/generate_circle_dataset.py index 6423d51..44f6366 100644 --- a/src/ddpm/generate_circle_dataset.py +++ b/src/ddpm/generate_circle_dataset.py @@ -1,14 +1,51 @@ import numpy as np +from scipy.ndimage import label, center_of_mass +from PIL import Image, ImageDraw + from tqdm import tqdm import os from concurrent.futures import ProcessPoolExecutor from itertools import repeat -RED = np.array((0xCC, 0x24, 0x1D)) -GREEN = np.array((0x98, 0x97, 0x1A)) -BLUE = np.array((0x45, 0x85, 0x88)) -BACKGROUND = np.array((0x50, 0x49, 0x45)) +import matplotlib.pyplot as plt + + +RED = (0xCC, 0x24, 0x1D) +GREEN = (0x98, 0x97, 0x1A) +BLUE = (0x45, 0x85, 0x88) +BACKGROUND = (0x50, 0x49, 0x45) + + +def create_sample_antialiased( + id: int, image_size: int, distance: int, radius: int, delta: int, scale=4 +): + # Scale up the image dimensions + high_res_size = image_size * scale + high_res_radius = radius * scale + + # Create a blank high-res image + im = Image.new("RGB", (high_res_size, high_res_size), BACKGROUND) + draw = ImageDraw.Draw(im) + + # Random centers for the two circles at high resolution + dist = float("inf") + while (dist < (distance - delta) * scale) or (dist > (distance + delta) * scale): + x0, y0 = np.random.randint( + low=high_res_radius, high=high_res_size - high_res_radius, size=2 + ) + x1, y1 = np.random.randint( + low=high_res_radius, high=high_res_size - high_res_radius, size=2 + ) + dist = np.sqrt((x0 - x1) ** 2 + (y0 - y1) ** 2) + + # Draw anti-aliased circles using PIL's ellipse method + draw.circle((x0, y0), high_res_radius, fill=GREEN) + draw.circle((x1, y1), high_res_radius, fill=BLUE) + + # Downsample the image back to the target resolution with anti-aliasing + im = im.resize((image_size, image_size), resample=Image.LANCZOS) + return id, np.array(im) def create_sample(id: int, image_size: int, distance: int, radius: int, delta: int): @@ -30,26 +67,52 @@ def create_sample(id: int, image_size: int, distance: int, radius: int, delta: i dist = np.sqrt((x0 - x1) ** 2 + (y0 - y1) ** 2) # Draw the circles + xx, yy = np.mgrid[:image_size, :image_size] - circle0 = (xx - x0) ** 2 + (yy - y0) ** 2 - circle1 = (xx - x1) ** 2 + (yy - y1) ** 2 - img = ( - img - + circle0[:, :, None] * GREEN[None, None, :] - + circle1[:, :, None] * BLUE[None, None, :] - ) + # Create boolean masks for the circles based on the radius + mask0 = (xx - x0) ** 2 + (yy - y0) ** 2 <= radius**2 + mask1 = (xx - x1) ** 2 + (yy - y1) ** 2 <= radius**2 + + # Apply the colors to the pixels where the mask is True + img[mask0] = GREEN + img[mask1] = BLUE return id, img -def generate_circle_dataset( - num_samples=1_000_000, - image_size=64, - radius=5, - distance=20, - delta=5, -): +def detect_circle_centers(image, background=BACKGROUND, threshold=30): + """ + Detects centers of circles in an image by finding connected components + that differ from the background color. + + Args: + image (np.ndarray): The image array with shape (H, W, 3). + background (np.ndarray): The background color to ignore. + threshold (int): The minimum per-channel difference to consider a pixel + as part of a circle. + + Returns: + centers (list of tuples): List of (row, col) coordinates for each detected circle. + """ + # Compute the absolute difference from the background for each pixel. + diff = np.abs(image.astype(np.int16) - np.array(background).astype(np.int16)) + # Create a mask where any channel difference exceeds the threshold. + mask = np.any(diff > threshold, axis=-1) + + # Label connected regions in the mask. + labeled, num_features = label(mask) + + centers = [] + # Compute the center of mass for each labeled region. + for i in range(1, num_features + 1): + center = center_of_mass(mask, labeled, i) + centers.append(center) + + return centers + + +def generate_circle_dataset(num_samples, image_size, radius, distance, delta): """ Generate a dataset of images with two circles (red and blue) and save as numpy tensors. @@ -63,7 +126,7 @@ def generate_circle_dataset( with ProcessPoolExecutor(max_workers=32) as executor: for i, sample in executor.map( - create_sample, + create_sample_antialiased, range(num_samples), repeat(image_size), repeat(distance), @@ -74,17 +137,52 @@ def generate_circle_dataset( yield i, sample +def visualize_samples(dataset: np.array, output_dir: str): + # Define the grid size (e.g., 5x5) + grid_size = 5 + fig, axes = plt.subplots(grid_size, grid_size, figsize=(10, 10)) + + for i in range(grid_size): + for j in range(grid_size): + idx = i * grid_size + j + if idx < len(dataset): + img = dataset[idx] + axes[i, j].imshow(img) + centers = detect_circle_centers(img) + # Plot each detected center in red. Note that center_of_mass returns (row, col) + for center in centers: + axes[i, j].scatter(center[1], center[0], c="red", s=20) + axes[i, j].axis("off") + + plt.tight_layout() + plt.savefig(os.path.join(output_dir, "sample_grid.png")) + plt.close() + + if __name__ == "__main__": # Create output directory if it doesn't exist total_samples = 1_000_000 - image_size = 64 + image_size = 32 + distance = 11 + delta = 4 + radius = 3 output_dir = "data/circle_dataset" os.makedirs(output_dir, exist_ok=True) dataset = np.empty((total_samples, image_size, image_size, 3), dtype=np.uint8) - iterator = generate_circle_dataset(num_samples=total_samples) + iterator = generate_circle_dataset( + image_size=image_size, + num_samples=total_samples, + distance=distance, + delta=delta, + radius=radius, + ) for i, sample in tqdm(iterator, total=total_samples): dataset[i] = sample - np.save(os.path.join(output_dir, "data_map.npy"), dataset) + visualize_samples(dataset, output_dir) + + # Save the dataset + np.save(os.path.join(output_dir, "data32.npy"), dataset) + # np.savez_compressed(os.path.join(output_dir, "data.npy.npz"), dataset) diff --git a/src/ddpm/sample.py b/src/ddpm/sample.py index de8bd87..ed2efea 100644 --- a/src/ddpm/sample.py +++ b/src/ddpm/sample.py @@ -1,16 +1,26 @@ from typing import Dict +from concurrent.futures import ProcessPoolExecutor import torch from tqdm import tqdm +import matplotlib import matplotlib.pyplot as plt +import seaborn as sns import numpy as np from train import extract, get_diffusion_params -from train import TIMESTEPS, IMAGE_SIZE, CHANNELS, DDIM_TIMESTEPS, NORM_MEAN, NORM_STD +from train import TIMESTEPS, IMAGE_SIZE, DDIM_TIMESTEPS from model import UNet +from generate_circle_dataset import detect_circle_centers + +matplotlib.use("Agg") + torch.manual_seed(1) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") +GENERATE_SYNTHETIC_DATA = False + +@torch.compile @torch.no_grad() def ddpm_sample( model: torch.nn.Module, @@ -83,11 +93,11 @@ def ddpm_sample_images( """Generate new images using the trained model""" x = torch.randn(batch_size, channels, image_size, image_size).to(device) - for t in tqdm(reversed(range(TIMESTEPS)), desc="DDPM Sampling", total=TIMESTEPS): + for t in tqdm( + reversed(range(TIMESTEPS)), desc="DDPM Sampling", total=TIMESTEPS, leave=False + ): t_batch = torch.full((batch_size,), t, device=device, dtype=torch.long) x = ddpm_sample(model, x, t_batch, params) - if t % 100 == 0: - show_images(x) if x.isnan().any(): raise ValueError(f"NaN detected in image at timestep {t}") @@ -121,53 +131,230 @@ def ddim_sample_images( for i in tqdm(range(len(timesteps) - 1), desc="DDIM Sampling"): t = torch.full((batch_size,), timesteps[i], device=device, dtype=torch.long) - x_before = x.clone() x = ddim_sample(model, x, t, params) if x.isnan().any(): raise ValueError(f"NaN detected at timestep {timesteps[i]}") - if i % 10 == 0: - show_images(x) return x def show_images(images: torch.Tensor, title=""): """Display a batch of images in a grid""" - mean = torch.tensor(NORM_MEAN).to(device).view(1, 3, 1, 1).expand_as(images) - std = torch.tensor(NORM_STD).to(device).view(1, 3, 1, 1).expand_as(images) - images = (images * std + mean).clip(0, 1) - images = images.detach().cpu().numpy() - for idx in range(min(16, len(images))): plt.subplot(4, 4, idx + 1) - plt.imshow(np.transpose(images[idx], (1, 2, 0))) + plt.imshow(images[idx]) + # plt.imshow(np.transpose(images[idx], (1, 2, 0))) plt.axis("off") plt.suptitle(title) - plt.draw() - plt.pause(0.001) + plt.savefig("media/circles-predicted.png") + plt.close() + + +def compute_statistics(data: np.array, generated: np.array): + data_centers = [] + num_bad_samples = 0 + # for centers in map(detect_circle_centers, data): + # if len(centers) == 2: + # data_centers.append(np.array(centers)) + # else: + # num_bad_samples += 1 + with ProcessPoolExecutor(max_workers=8) as executor: + for centers in executor.map(detect_circle_centers, data, chunksize=8): + if len(centers) == 2: + data_centers.append(np.array(centers)) + else: + num_bad_samples += 1 + + if num_bad_samples > 0: + print("num bad samples in data: ", num_bad_samples) + + data_centers = np.stack(data_centers, axis=0) # (num_samples, 2, 2) + + num_bad_samples = 0 + generated_centers = [] + with ProcessPoolExecutor(max_workers=16) as executor: + for centers in executor.map(detect_circle_centers, generated, chunksize=8): + if len(centers) == 2: + generated_centers.append(np.array(centers)) + else: + num_bad_samples += 1 + + if num_bad_samples > 0: + print("num bad samples in generated: ", num_bad_samples) + + generated_centers = np.stack(generated_centers, axis=0) # (num_samples, 2, 2) + + # Calculate distances from the center of the image + # image_center = IMAGE_SIZE / 2 + # data_distances = np.sqrt( + # (data_centers[:, 0] - image_center) ** 2 + # + (data_centers[:, 1] - image_center) ** 2 + # ) + # generated_distances = np.sqrt( + # (generated_centers[:, 0] - image_center) ** 2 + # + (generated_centers[:, 1] - image_center) ** 2 + # ) + + # Create a figure with subplots + + plt.figure(figsize=(15, 10)) + + # Plot histogram of x positions + plt.subplot(2, 2, 1) + sns.histplot( + data_centers[:, :, 0].reshape(-1), + color="blue", + label="Data", + kde=True, + stat="density", + ) + sns.histplot( + generated_centers[:, :, 0].reshape(-1), + color="orange", + label="Generated", + kde=True, + stat="density", + ) + plt.title("X Position Distribution") + plt.xlabel("X Position") + plt.legend() + + # Plot histogram of y positions + plt.subplot(2, 2, 2) + sns.histplot( + data_centers[:, :, 1].reshape(-1), + color="blue", + label="Data", + kde=True, + stat="density", + ) + sns.histplot( + generated_centers[:, :, 1].reshape(-1), + color="orange", + label="Generated", + kde=True, + stat="density", + ) + plt.title("Y Position Distribution") + plt.xlabel("Y Position") + plt.legend() + + # Plot histogram of distances + plt.subplot(2, 2, 3) + distances = np.sqrt( + np.square(data_centers[:, ::2, 0] - data_centers[:, 1::2, 0]) + + np.square(data_centers[:, ::2, 1] - data_centers[:, 1::2, 1]) + ).squeeze() + generated_distances = np.sqrt( + np.square(generated_centers[:, ::2, 0] - generated_centers[:, 1::2, 0]) + + np.square(generated_centers[:, ::2, 1] - generated_centers[:, 1::2, 1]) + ).squeeze() + sns.histplot(distances, color="blue", label="Data", kde=True, stat="density") + sns.histplot( + generated_distances, color="orange", label="Generated", kde=True, stat="density" + ) + plt.title("Distance between circles distribution") + plt.xlabel("Distance") + plt.legend() + + # Plot 2D heatmap of center positions + plt.subplot(2, 2, 4) + sns.kdeplot( + x=data_centers[:, :, 0].reshape(-1), + y=data_centers[:, :, 1].reshape(-1), + cmap="Blues", + label="Data", + ) + sns.kdeplot( + x=generated_centers[:, :, 0].reshape(-1), + y=generated_centers[:, :, 1].reshape(-1), + cmap="Oranges", + label="Generated", + ) + plt.title("2D Heatmap of Center Positions") + plt.xlabel("X Position") + plt.ylabel("Y Position") + plt.xlim(0, IMAGE_SIZE) + plt.ylim(0, IMAGE_SIZE) + plt.legend() + + plt.tight_layout() + plt.savefig("media/center_statistics.png") + print("Saved histograms at media/center_statistics.png") + plt.close() + + +def load_dataset(data_path: str): + print("Loading dataset...", end="", flush=True) + data = np.load(data_path) + print("Done.") + return data + + +def plot_bad_centers(generated: np.array): + generated_centers = [] + num_bad_samples = 0 + with ProcessPoolExecutor(max_workers=16) as executor: + for centers in executor.map(detect_circle_centers, generated, chunksize=8): + if len(centers) == 2: + generated_centers.append(np.array(centers)) + else: + num_bad_samples += 1 + generated_centers.append(np.zeros((2, 2))) + + if num_bad_samples > 0: + print("num bad samples in generated: ", num_bad_samples) + + generated_centers = np.stack(generated_centers, axis=0) # (num_samples, 2, 2) + generated_distances = np.sqrt( + np.square(generated_centers[:, ::2, 0] - generated_centers[:, 1::2, 0]) + + np.square(generated_centers[:, ::2, 1] - generated_centers[:, 1::2, 1]) + ).squeeze() + + mask = generated_distances > 18.0 + generated = generated[mask] + show_images(generated) if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + plt.figure(figsize=(10, 10)) - params = get_diffusion_params(TIMESTEPS, device, eta=0.0) - - model = UNet(32, TIMESTEPS).to(device) - model.load_state_dict(torch.load("model.pkl", weights_only=True)) - - model.eval() - generated_images = ( - ddim_sample_images( # change to ddim_sample_images here to enable DDIM - model=model, - image_size=IMAGE_SIZE, - batch_size=16, - channels=CHANNELS, - device=device, - params=params, - ) - ) - show_images(generated_images, title="Generated Images") + data = load_dataset("./data/circle_dataset/data32.npy") + + if GENERATE_SYNTHETIC_DATA: + nb_synthetic_samples = 10_000 + params = get_diffusion_params(TIMESTEPS, device, eta=0.0) + + model = UNet(32, TIMESTEPS).to(device) + model.load_state_dict(torch.load("model.pkl", weights_only=True)) + + model.eval() + generated = np.empty_like(data) + chunk = 500 + samples = min(nb_synthetic_samples, data.shape[0]) + for i in tqdm(range(samples // chunk), desc="Generating synthetic data."): + generated_images = ddpm_sample_images( + model=model, + image_size=IMAGE_SIZE, + batch_size=chunk, + channels=3, + device=device, + params=params, + ) + generated_images = torch.permute(generated_images, (0, 2, 3, 1)) + generated[i * chunk : (i + 1) * chunk] = ( + (generated_images * 255.0) + .clip(min=0.0, max=255.0) + .cpu() + .numpy() + .astype(np.uint8) + ) + np.save("./data/circle_dataset/generated32.npy", generated) + else: + generated = np.load("./data/circle_dataset/generated32.npy") - # Keep the plot open after generation is finished - plt.show() + compute_statistics(data, generated) + plot_bad_centers(generated) diff --git a/src/ddpm/train.py b/src/ddpm/train.py index 2d6ffbb..40872d5 100644 --- a/src/ddpm/train.py +++ b/src/ddpm/train.py @@ -1,46 +1,40 @@ from typing import Dict, Callable +from itertools import islice +import numpy as np import torch -from torchvision import datasets, transforms from torch.utils.data import DataLoader from tqdm import tqdm from model import UNet -# Set random seed for reproducibility -torch.manual_seed(42) -device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - # Hyperparameters -NUM_EPOCHS = 256 -BATCH_SIZE = 128 +NUM_EPOCHS = 1 +BATCH_SIZE = 512 IMAGE_SIZE = 32 CHANNELS = 3 TIMESTEPS = 1000 -DDIM_TIMESTEPS = 100 +DDIM_TIMESTEPS = 500 -NORM_MEAN = (0.4914, 0.4822, 0.4465) -NORM_STD = (0.2470, 0.2435, 0.2616) +def load_dataset(data_path: str): + print("Loading dataset... ", end="", flush=True) + data = np.load(data_path).astype(np.float32) + data = data / 255.0 # normalize between [0-1] + data = np.permute_dims(data, (0, 3, 1, 2)) # (b, h, w, c) -> (b, c, h, w) + print("Done.") + return data -def count_parameters(model): - return sum(p.numel() for p in model.parameters() if p.requires_grad) +def create_dataset_loader(data: np.array): + nb_batches = data.shape[0] // BATCH_SIZE + ids = np.arange(data.shape[0]) -transform = transforms.Compose( - [ - transforms.Resize(IMAGE_SIZE), - transforms.RandomHorizontalFlip(0.5), - transforms.ToTensor(), - transforms.Normalize(NORM_MEAN, NORM_STD), - ] -) + for i in range(nb_batches): + batch_ids = ids[i * BATCH_SIZE : (i + 1) * BATCH_SIZE] + yield data[batch_ids] -train_dataset = datasets.CIFAR10( - root="./data", train=True, download=True, transform=transform -) -train_loader = DataLoader( - train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=8, pin_memory=True -) +def count_parameters(model): + return sum(p.numel() for p in model.parameters() if p.requires_grad) def get_diffusion_params( @@ -91,7 +85,28 @@ def extract(a: torch.Tensor, t: torch.Tensor, x_shape: torch.Tensor.shape): return out.reshape(batch_size, *((1,) * (len(x_shape) - 1))).to(t.device) +def get_lr( + it: int, + warmup_iters: int = 80, + lr_decay_iters: int = 900, + min_lr: float = 3e-5, + learning_rate: float = 1e-4, +): + # 1) linear warmup for warmup_iters steps + if it < warmup_iters: + return learning_rate * (it + 1) / (warmup_iters + 1) + # 2) if it > lr_decay_iters, return min learning rate + if it > lr_decay_iters: + return min_lr + # 3) in between, use cosine decay down to min learning rate + decay_ratio = (it - warmup_iters) / (lr_decay_iters - warmup_iters) + assert 0 <= decay_ratio <= 1 + coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio)) # coeff ranges 0..1 + return min_lr + coeff * (learning_rate - min_lr) + + def get_loss_fn(model: torch.nn.Module, params: Dict[str, torch.Tensor]) -> Callable: + @torch.compile def loss_fn(x_0): batch_size = x_0.shape[0] t = torch.randint(0, TIMESTEPS, (batch_size,), device=device) @@ -116,9 +131,13 @@ def train_epoch( model.train() total_loss = 0 - with tqdm(train_loader, leave=False) as pbar: + with tqdm(islice(train_loader, 200), leave=False) as pbar: + steps = 0 for batch in pbar: - images = batch[0].to(device) + # lr = get_lr(steps) + # for param_group in optimizer.param_groups: + # param_group["lr"] = lr + images = torch.tensor(batch, device=device) optimizer.zero_grad() loss = loss_fn(images) @@ -126,12 +145,19 @@ def train_epoch( optimizer.step() total_loss += loss.item() + steps += 1 pbar.set_description(f"Loss: {loss.item():.4f}") - return total_loss / len(train_loader) + return total_loss / steps if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + # Set random seed for reproducibility + torch.manual_seed(42) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model = UNet(32, TIMESTEPS).to(device) nb_params = count_parameters(model) print(f"Total number of parameters: {nb_params}") @@ -140,8 +166,11 @@ if __name__ == "__main__": params = get_diffusion_params(TIMESTEPS, device) loss_fn = get_loss_fn(model, params) + data = load_dataset("./data/circle_dataset/data32.npy") + # Main training loop for e in tqdm(range(NUM_EPOCHS)): + train_loader = create_dataset_loader(data) train_epoch(model, optimizer, train_loader, loss_fn) # Save model after training diff --git a/uv.lock b/uv.lock index e6bba6b..34be970 100644 --- a/uv.lock +++ b/uv.lock @@ -68,6 +68,8 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy" }, { name = "pyqt6" }, + { name = "scipy" }, + { name = "seaborn" }, { name = "torch" }, { name = "torchvision" }, { name = "tqdm" }, @@ -78,6 +80,8 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.9.2" }, { name = "numpy", specifier = ">=2.1.2" }, { name = "pyqt6", specifier = ">=6.7.1" }, + { name = "scipy", specifier = ">=1.15.2" }, + { name = "seaborn", specifier = ">=0.13.2" }, { name = "torch", specifier = ">=2.5.0" }, { name = "torchvision", specifier = ">=0.20.0" }, { name = "tqdm", specifier = ">=4.66.5" }, @@ -447,6 +451,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, +] + [[package]] name = "pillow" version = "11.1.0" @@ -554,6 +592,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, +] + +[[package]] +name = "scipy" +version = "1.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/5d/3c78815cbab499610f26b5bae6aed33e227225a9fa5290008a733a64f6fc/scipy-1.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c4697a10da8f8765bb7c83e24a470da5797e37041edfd77fd95ba3811a47c4fd", size = 38756184 }, + { url = "https://files.pythonhosted.org/packages/37/20/3d04eb066b471b6e171827548b9ddb3c21c6bbea72a4d84fc5989933910b/scipy-1.15.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:869269b767d5ee7ea6991ed7e22b3ca1f22de73ab9a49c44bad338b725603301", size = 30163558 }, + { url = "https://files.pythonhosted.org/packages/a4/98/e5c964526c929ef1f795d4c343b2ff98634ad2051bd2bbadfef9e772e413/scipy-1.15.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bad78d580270a4d32470563ea86c6590b465cb98f83d760ff5b0990cb5518a93", size = 22437211 }, + { url = "https://files.pythonhosted.org/packages/1d/cd/1dc7371e29195ecbf5222f9afeedb210e0a75057d8afbd942aa6cf8c8eca/scipy-1.15.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:b09ae80010f52efddb15551025f9016c910296cf70adbf03ce2a8704f3a5ad20", size = 25232260 }, + { url = "https://files.pythonhosted.org/packages/f0/24/1a181a9e5050090e0b5138c5f496fee33293c342b788d02586bc410c6477/scipy-1.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6fd6eac1ce74a9f77a7fc724080d507c5812d61e72bd5e4c489b042455865e", size = 35198095 }, + { url = "https://files.pythonhosted.org/packages/c0/53/eaada1a414c026673eb983f8b4a55fe5eb172725d33d62c1b21f63ff6ca4/scipy-1.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b871df1fe1a3ba85d90e22742b93584f8d2b8e6124f8372ab15c71b73e428b8", size = 37297371 }, + { url = "https://files.pythonhosted.org/packages/e9/06/0449b744892ed22b7e7b9a1994a866e64895363572677a316a9042af1fe5/scipy-1.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:03205d57a28e18dfd39f0377d5002725bf1f19a46f444108c29bdb246b6c8a11", size = 36872390 }, + { url = "https://files.pythonhosted.org/packages/6a/6f/a8ac3cfd9505ec695c1bc35edc034d13afbd2fc1882a7c6b473e280397bb/scipy-1.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:601881dfb761311045b03114c5fe718a12634e5608c3b403737ae463c9885d53", size = 39700276 }, + { url = "https://files.pythonhosted.org/packages/f5/6f/e6e5aff77ea2a48dd96808bb51d7450875af154ee7cbe72188afb0b37929/scipy-1.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:e7c68b6a43259ba0aab737237876e5c2c549a031ddb7abc28c7b47f22e202ded", size = 40942317 }, + { url = "https://files.pythonhosted.org/packages/53/40/09319f6e0f276ea2754196185f95cd191cb852288440ce035d5c3a931ea2/scipy-1.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01edfac9f0798ad6b46d9c4c9ca0e0ad23dbf0b1eb70e96adb9fa7f525eff0bf", size = 38717587 }, + { url = "https://files.pythonhosted.org/packages/fe/c3/2854f40ecd19585d65afaef601e5e1f8dbf6758b2f95b5ea93d38655a2c6/scipy-1.15.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:08b57a9336b8e79b305a143c3655cc5bdbe6d5ece3378578888d2afbb51c4e37", size = 30100266 }, + { url = "https://files.pythonhosted.org/packages/dd/b1/f9fe6e3c828cb5930b5fe74cb479de5f3d66d682fa8adb77249acaf545b8/scipy-1.15.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:54c462098484e7466362a9f1672d20888f724911a74c22ae35b61f9c5919183d", size = 22373768 }, + { url = "https://files.pythonhosted.org/packages/15/9d/a60db8c795700414c3f681908a2b911e031e024d93214f2d23c6dae174ab/scipy-1.15.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:cf72ff559a53a6a6d77bd8eefd12a17995ffa44ad86c77a5df96f533d4e6c6bb", size = 25154719 }, + { url = "https://files.pythonhosted.org/packages/37/3b/9bda92a85cd93f19f9ed90ade84aa1e51657e29988317fabdd44544f1dd4/scipy-1.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de9d1416b3d9e7df9923ab23cd2fe714244af10b763975bea9e4f2e81cebd27", size = 35163195 }, + { url = "https://files.pythonhosted.org/packages/03/5a/fc34bf1aa14dc7c0e701691fa8685f3faec80e57d816615e3625f28feb43/scipy-1.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb530e4794fc8ea76a4a21ccb67dea33e5e0e60f07fc38a49e821e1eae3b71a0", size = 37255404 }, + { url = "https://files.pythonhosted.org/packages/4a/71/472eac45440cee134c8a180dbe4c01b3ec247e0338b7c759e6cd71f199a7/scipy-1.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5ea7ed46d437fc52350b028b1d44e002646e28f3e8ddc714011aaf87330f2f32", size = 36860011 }, + { url = "https://files.pythonhosted.org/packages/01/b3/21f890f4f42daf20e4d3aaa18182dddb9192771cd47445aaae2e318f6738/scipy-1.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11e7ad32cf184b74380f43d3c0a706f49358b904fa7d5345f16ddf993609184d", size = 39657406 }, + { url = "https://files.pythonhosted.org/packages/0d/76/77cf2ac1f2a9cc00c073d49e1e16244e389dd88e2490c91d84e1e3e4d126/scipy-1.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:a5080a79dfb9b78b768cebf3c9dcbc7b665c5875793569f48bf0e2b1d7f68f6f", size = 40961243 }, + { url = "https://files.pythonhosted.org/packages/4c/4b/a57f8ddcf48e129e6054fa9899a2a86d1fc6b07a0e15c7eebff7ca94533f/scipy-1.15.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:447ce30cee6a9d5d1379087c9e474628dab3db4a67484be1b7dc3196bfb2fac9", size = 38870286 }, + { url = "https://files.pythonhosted.org/packages/0c/43/c304d69a56c91ad5f188c0714f6a97b9c1fed93128c691148621274a3a68/scipy-1.15.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c90ebe8aaa4397eaefa8455a8182b164a6cc1d59ad53f79943f266d99f68687f", size = 30141634 }, + { url = "https://files.pythonhosted.org/packages/44/1a/6c21b45d2548eb73be9b9bff421aaaa7e85e22c1f9b3bc44b23485dfce0a/scipy-1.15.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:def751dd08243934c884a3221156d63e15234a3155cf25978b0a668409d45eb6", size = 22415179 }, + { url = "https://files.pythonhosted.org/packages/74/4b/aefac4bba80ef815b64f55da06f62f92be5d03b467f2ce3668071799429a/scipy-1.15.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:302093e7dfb120e55515936cb55618ee0b895f8bcaf18ff81eca086c17bd80af", size = 25126412 }, + { url = "https://files.pythonhosted.org/packages/b1/53/1cbb148e6e8f1660aacd9f0a9dfa2b05e9ff1cb54b4386fe868477972ac2/scipy-1.15.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd5b77413e1855351cdde594eca99c1f4a588c2d63711388b6a1f1c01f62274", size = 34952867 }, + { url = "https://files.pythonhosted.org/packages/2c/23/e0eb7f31a9c13cf2dca083828b97992dd22f8184c6ce4fec5deec0c81fcf/scipy-1.15.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d0194c37037707b2afa7a2f2a924cf7bac3dc292d51b6a925e5fcb89bc5c776", size = 36890009 }, + { url = "https://files.pythonhosted.org/packages/03/f3/e699e19cabe96bbac5189c04aaa970718f0105cff03d458dc5e2b6bd1e8c/scipy-1.15.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:bae43364d600fdc3ac327db99659dcb79e6e7ecd279a75fe1266669d9a652828", size = 36545159 }, + { url = "https://files.pythonhosted.org/packages/af/f5/ab3838e56fe5cc22383d6fcf2336e48c8fe33e944b9037fbf6cbdf5a11f8/scipy-1.15.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f031846580d9acccd0044efd1a90e6f4df3a6e12b4b6bd694a7bc03a89892b28", size = 39136566 }, + { url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914 }, +] + [[package]] name = "setuptools" version = "77.0.3" @@ -673,3 +772,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3 wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +]