# Import built-in Python modules from math import e # Import libraries for data manipulation import pandas as pd import numpy as np # Import libraries for data visualization import matplotlib.pyplot as plt import seaborn as sns # Import libraries for geospatial data and maps import folium from folium.plugins import MarkerCluster from geopy.distance import geodesic import branca # Import libraries for display (e.g., in Jupyter Notebook) from IPython.display import display # Import libraries for statistics and modeling from scipy.spatial import cKDTree from scipy.stats import chi2_contingency import pingouin as pg from scipy.stats import pearsonr from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA def plot_comparison_between_original_and_corrected_dataset(df, df_refined): # Calculate the number of non-missing values for each column non_missing_original = df.notnull().sum() non_missing_refined = df_refined.notnull().sum() # Prepare data for the bar plot comparison_data = pd.DataFrame({ 'Columns': non_missing_original.index, 'Original Dataset': non_missing_original.values, 'Corrected Dataset': non_missing_refined.values }).melt(id_vars='Columns', var_name='Dataset', value_name='Non-Missing Count') # Create the bar plot plt.figure(figsize=(12, 6)) ax = sns.barplot( data=comparison_data, x='Columns', y='Non-Missing Count', hue='Dataset', palette='pastel' ) # Rotate the x-axis labels plt.xticks(rotation=45, ha='right') # Add count annotations to each bar (inside the bars) for p in ax.patches: height = p.get_height() # Bar height (value of the bar) if height > 0: # Only annotate bars with positive height ax.annotate( f'{int(height)}', # Display the count as an integer (p.get_x() + p.get_width() / 2., height / 2), # Position inside the bar, vertically centered ha='center', va='center', fontsize=10, color='black', rotation=90 ) # Add plot titles and labels plt.title('Comparison of Non-Missing Values by Column', fontsize=14) plt.ylabel('Number of Non-Missing Values', fontsize=12) plt.xlabel('Columns', fontsize=12) plt.legend(title='Dataset', loc='upper right') plt.grid(axis='y', linestyle='--', alpha=0.7) plt.tight_layout() # Adjust layout to prevent overlap plt.show() def plot_differences_between_original_and_corrected_dataset(df, df_refined): # Calculate the number of non-missing values for each column non_missing_original = df.notnull().sum() non_missing_refined = df_refined.notnull().sum() # Create a DataFrame for plotting diff_df = data_diff = (non_missing_refined - non_missing_original).reset_index() diff_df.columns = ['Columns', 'Difference'] # Plot the data using Seaborn plt.figure(figsize=(10, 6)) ax = sns.barplot( data=diff_df, x=diff_df.columns[0], y=diff_df.columns[1], hue = diff_df.columns[0], legend = False, palette=["blue" if x > 0 else "red" for x in diff_df['Difference']] # Blue for positive, Red for negative ) # Set the y-axis to be centered at 0 ax.axhline(0, color='black',linewidth=1) # Add plot titles and labels plt.title('Differences Between Original and Corrected Datasets counts', fontsize=14) plt.ylabel('Difference (Original - Corrected)', fontsize=12) plt.xlabel('Columns', fontsize=12) # Rotate the x-axis labels plt.xticks(rotation=45, ha='right') # Display the values inside the bars for p in ax.patches: height = p.get_height() ax.text( p.get_x() + p.get_width() / 2, # Position at the center of the bar height/2, # Position at the top of the bar f'{int(height)}', # Annotate with the number ha='center', va='center', fontsize=10, color='k', rotation=0 ) # Show the plot plt.tight_layout() # Adjust the layout to avoid overlap plt.show() def plot_univariate_variable_analysis( df: pd.DataFrame, variable_name: str, graph_type: str, variable_title_type: str = None, rotation: float = 90, is_splitted: bool = False, ) -> None: """ Plots the distribution of a specified variable in a DataFrame based on the graph type. Args: df (pd.DataFrame): The DataFrame containing the data. variable_name (str): The name of the variable to plot. graph_type (str): The type of graph to plot (e.g., 'pie', 'heat_map', 'bars', 'hist', 'bars_log'). variable_title_type (str, optional): An optional title type for the variable. Defaults to None. Raises: ValueError: If the graph_type is not recognized or if latitude/longitude columns are missing. """ if variable_title_type is None: variable_title_type = graph_type graph_types = ['pie', 'heat_map', 'bars', 'hist', 'bars_log', 'boxplot'] graph_type = graph_type.lower() graph_type_error = True for g_type in graph_types: if g_type in graph_type: graph_type_error = False if graph_type_error: raise ValueError('Error: wrong graph_type') if 'pie' in graph_type: if '%' in graph_type: threshold = float(graph_type.split('_')[-1][:-1]) else: threshold = 2 value_counts = df[variable_name].value_counts() # Get counts without normalization total = value_counts.sum() # Total count for percentage calculation # Group classes with less than 2% into "Others" (using raw counts) others_count = value_counts[value_counts / total * 100 < threshold] if not others_count.empty: # others_label = f"Others ({', '.join(others_count.index)})" others_label = "Others" value_counts = value_counts[value_counts / total * 100 >= threshold] value_counts[others_label] = others_count.sum() # Add the "Others" category plt.pie( value_counts, labels=[f"{label} ({count/total*100:.1f}%)" for label, count in zip(value_counts.index, value_counts.values)], # Percentage in labels autopct=lambda p: f'{int(p * total / 100)}' # Count on wedges ) plt.title(f"{variable_name} Distribution") plt.show() elif 'bars' in graph_type or 'bars_log' in graph_type: value_counts = df[variable_name].value_counts() tail = graph_type.split('_')[-1] if tail.isdigit(): threshold = int(tail) else: threshold = 26 # Group smaller classes into "others" if there are more than 26 if len(value_counts) > threshold: top_categories = value_counts[:threshold-1] others_count = value_counts[threshold-1:].sum() value_counts = pd.concat([top_categories, pd.Series({f'others ({len(value_counts[threshold-1:])})': others_count})]) if is_splitted: total = df.index.nunique() else: total = value_counts.sum() plt.figure(figsize=(10, 6)) ax = sns.barplot(x=value_counts.index, y=value_counts.values) plt.title(f"{variable_name} Distribution") # Add percentages inside the bars for p, percentage in zip(ax.patches, value_counts / total * 100): rotation = rotation if len(value_counts) > 10 else 0 ax.annotate( f"{percentage:.1f}%", (p.get_x() + p.get_width() * 0.50, p.get_height() * 0.90), ha='center', va='center', fontsize=10, rotation=90, color='black' ) plt.xlabel(variable_name) # Set the X-axis label plt.ylabel('Count') # Set the Y-axis label # Rotate X-axis labels if there are too many categories plt.xticks(rotation=rotation if len(value_counts) > 10 else 0) # Check if the graph should have a logarithmic scale if graph_type == 'bars_log': plt.yscale('log') # Apply a logarithmic scale to the Y-axis # Get the current Y-axis ticks current_ticks = plt.yticks()[0] # Retrieve the existing tick positions if graph_type =='bars_log': new_ticks = list(current_ticks) + [total] # Add the custom tick new_ticks = sorted(new_ticks) # Sort ticks to maintain proper tick order else: # Handle the case for a cartesian (linear) scale new_ticks = sorted(list(current_ticks) + [total]) # Add the custom tick plt.gca().set_yticks(new_ticks) # Update ticks on the Y-axis # Display the final plot plt.show() elif graph_type == 'hist': plt.figure(figsize=(10, 6)) # sns.histplot(df[variable_name][df[variable_name] <= df[variable].quantile(0.99)], kde=True) sns.histplot(df[variable_name], kde=True, color='blue', bins=15) plt.title(f"{variable_name} Distribution") plt.xlabel(variable_name) plt.ylabel('Count') plt.show() elif graph_type == "boxplot": # sns.boxplot(y=df[variable_name][df[variable_name] <= df[variable_name].quantile(0.99)]) sns.boxplot(y=df[variable_name]) plt.title(f"{variable_name} Distribution (Boxplot)") plt.ylabel(variable_name) plt.show() elif graph_type == 'heat_map': # Check if the DataFrame contains the necessary geographic columns if 'geo_point_2d_a' in df.columns and 'geo_point_2d_b' in df.columns and variable_name in df.columns: # Group by the variable of interest (e.g., 'city', 'region') and count the occurrences in each sector sector_data = df.groupby([variable_name]).agg({ 'geo_point_2d_a': 'mean', 'geo_point_2d_b': 'mean', }).reset_index() # Add a "population density" column based on the number of occurrences of the variable in each sector sector_data['counts'] = df.groupby(variable_name).size().values # Calculate the occurrence percentages total_counts = sector_data['counts'].sum() sector_data['percentage'] = (sector_data['counts'] / total_counts) * 100 # Common zoom level used for city-level maps in Folium (and Leaflet) zoom_level = 12 # Create a map centered around the average geographic points m = folium.Map(location=[sector_data['geo_point_2d_a'].mean(), sector_data['geo_point_2d_b'].mean()], zoom_start=zoom_level) # Normalize population densities for the heat map min_density = sector_data['counts'].min() max_density = sector_data['counts'].max() # Create the colormap with branca colormap = branca.colormap.LinearColormap( colors=['yellow', 'orange', 'red'], # Choose a classic heatmap palette (yellow-orange-red) vmin=min_density, vmax=max_density ).to_step(10) # Divide the colormap into 10 steps for the legend # Extract coordinates of the sectors coords = sector_data[['geo_point_2d_a', 'geo_point_2d_b']].values tree = cKDTree(coords) # Create the k-d tree with the coordinates # Initialize the minimum distance to a very large number min_distance = float('inf') closest_pair = None # Find the closest pairs for i, point in enumerate(coords): # Find the closest neighbor from point i (excluding the point itself) distances, indices = tree.query(point, k=2) # k=2 to get the closest neighbor and itself # The second closest will be the nearest (the first is the point itself) closest_distance = distances[1] closest_index = indices[1] # If the found distance is smaller, update the minimum distance and associated points if closest_distance < min_distance: min_distance = closest_distance closest_pair = (point, coords[closest_index]) # Convert the minimum Euclidean distance to geodesic distance for more accuracy min_distance = geodesic(closest_pair[0], closest_pair[1]).meters # Limit the circle size to half the smallest distance between two points max_circle_radius = min_distance / 2 # Average Earth radius in meters EARTH_RADIUS = 6371000 # Calculate the size of a pixel in meters (at the given average latitude) lat_rad = np.radians(np.mean(sector_data['geo_point_2d_b'])) meters_per_pixel = (2 * np.pi * EARTH_RADIUS) / (256 * (2 ** zoom_level)) * np.cos(lat_rad) max_circle_radius_in_pixels = max_circle_radius / meters_per_pixel # Add each sector as a colored circle based on population density for _, row in sector_data.iterrows(): color = colormap(row['counts']) # Use the colormap to get the color based on density # Calculate the size of the marker based on density # size = np.log(row['population_density'] + 1) * 5 # Logarithmic for a reasonable scale # size = max(10, min(size, max_circle_radius)) # Limit the size to max_circle_radius size = max_circle_radius_in_pixels # Add a circle to the map folium.CircleMarker( location=[row['geo_point_2d_a'], row['geo_point_2d_b']], radius=size, # Circle size based on density and minimum distance color=color, fill=True, fill_color=color, # Fill with the same color fill_opacity=0.7, popup=f"{variable_name}: {row[variable_name]}
Trees: {row['counts']}
Percentage: {row['percentage']:.3}%", # Allow the size to adjust based on zoom level zoom_on_click=True ).add_to(m) # Add the colormap legend to the map colormap.caption = f"Trees per {variable_name}" colormap.add_to(m) # Force the legend to the bottom left corner using CSS style m.get_root().html.add_child(folium.Element(""" """)) # Display the map display(m) m.save(f'./map_{variable_name}.html') return # End the function else: raise ValueError("The DataFrame must contain 'geo_point_2d_a', 'geo_point_2d_b', and the sector variable to display a map.") def plot_bivariate_variable_analysis( x_series: pd.Series, y_series: pd.Series, graph_type: str, title: str = None, ) -> None: """ Plots the distribution of a specified variable using two Pandas Series and a specified graph type. Args: x_series (pd.Series): The first variable to plot (x-axis). y_series (pd.Series): The second variable to plot (y-axis). graph_type (str): The type of graph to plot (e.g., 'boxplot', 'scatter'). variable_title_type (str, optional): An optional title for the graph. Defaults to None. Raises: ValueError: If the graph_type is not recognized. """ # Define the supported graph types graph_types = ['boxplot', 'scatter'] graph_type = graph_type.lower() # Convert the graph type to lowercase for case insensitivity # Raise an error if the specified graph type is not supported if graph_type not in graph_types: raise ValueError( f"The graph type '{graph_type}' is not supported. " f"Accepted types: {', '.join(graph_types)}" ) # Plot a boxplot if the specified graph type is "boxplot" if graph_type == "boxplot": boxplot_bivariate_analysis(x_series, y_series, title) # Plot a scatter plot if the specified graph type is "scatter" elif graph_type == "scatter": scatter_plot(x_series, y_series, title) def boxplot_bivariate_analysis( x_series: pd.Series, y_series: pd.Series, title: str = None, ) -> None: """ Plots a boxplot for the specified x_data and y_data. Args: x_series (pd.Series): The data for the x-axis. y_series (pd.Series): The data for the y-axis. """ # Filter out NaN or infinite values from both series idx = np.isfinite(x_series) & np.isfinite(y_series) x_clean = x_series[idx] y_clean = y_series[idx] # Create the boxplot using Seaborn plt.figure(figsize=(8, 6)) # Set the figure size sns.boxplot(x=x_clean, y=y_clean) # Generate the boxplot if title == None: plt.title(f'Boxplot {x_series.name} vs. {y_series.name}') # Add the title else : plt.title(title) plt.xlabel(x_series.name) # Set x-axis label plt.ylabel(y_series.name) # Set y-axis label plt.xticks(rotation=90) # Rotate the x-axis labels for better readability plt.show() # Display the boxplot def scatter_plot( x_series: pd.Series, y_series: pd.Series, title: str = None, ) -> None: """ Plots a scatter plot for the specified x_data and y_data and overlays a linear regression line. Args: x_series (pd.Series): The data for the x-axis. y_series (pd.Series): The data for the y-axis. """ # Filter out NaN or infinite values from both series idx = np.isfinite(x_series) & np.isfinite(y_series) x_clean = x_series[idx] y_clean = y_series[idx] # Calculate the linear regression line coefficients (slope and intercept) a, b = np.polyfit(x_clean, y_clean, 1) # Fit a polynomial of degree 1 (linear regression) # Create the scatter plot with Seaborn plt.figure(figsize=(10, 6)) # Set the figure size sns.scatterplot(x=x_clean, y=y_clean, color='blue', alpha=0.6) # Generate scatterplot # Plot the linear regression line x_fit = np.linspace(min(x_clean), max(x_clean), 100) # Generate values for the x-axis of the line y_fit = a * x_fit + b # Calculate corresponding y values for the line plt.plot(x_fit, y_fit, color='red', label=f'y = {format_coefficient(a)}x + {format_coefficient(b)}') # Add the line # Customize the plot if title == None: plt.title("Scatter Plot with Linear Regression Line") # Add the plot title else: plt.title(title) plt.xlabel(f'{x_series.name}') # Set x-axis label plt.ylabel(f'{y_series.name}') # Set y-axis label plt.legend() # Display the legend plt.grid() # Add a grid for better readability # Display the plot plt.show() ### Main bivariate indicators function def compute_bivariate_stats_indicators(x_series: pd.Series, y_series: pd.Series) -> None: """ Detects the types of series (categorical or quantitative) and determines the appropriate statistical analysis (ANOVA, Pearson Correlation, Chi-Squared Test). Args: x_series (pd.Series): The first series. y_series (pd.Series): The second series. Returns: None: Results are printed to the console. """ # Step 1: Determine the type of each series if isinstance(x_series.dropna().iloc[0], str): x_type = 'categorical' else: x_type = 'quantitative' if isinstance(y_series.dropna().iloc[0], str): y_type = 'categorical' else: y_type = 'quantitative' # Step 2: Call the appropriate function based on the types if x_type == 'quantitative' and y_type == 'quantitative': # Both series are quantitative -> Pearson Correlation corr, p_value = perform_pearson_correlation(x_series, y_series) result = corr, p_value elif x_type == 'categorical' and y_type == 'categorical': # Both series are categorical -> Chi-Squared Test contingency_table, v_cramer, p, chi2, dof, expected = perform_chi2_test(x_series, y_series) result = contingency_table, v_cramer, p, chi2, dof, expected elif (x_type == 'categorical' and y_type == 'quantitative') or ( x_type == 'quantitative' and y_type == 'categorical' ): # One series is categorical and the other is quantitative -> ANOVA anova_table, eta_squared, p_value = perform_anova(x_series, y_series) result = anova_table, eta_squared, p_value else: raise ValueError("Invalid combination of variable types.") return result ### Pearson Correlation Function def perform_pearson_correlation(x_series: pd.Series, y_series: pd.Series) -> tuple: """ Computes the Pearson correlation coefficient for two quantitative variables. Args: x_series (pd.Series): The first quantitative series. y_series (pd.Series): The second quantitative series. Returns: tuple: A tuple containing: - the correlation coefficient (corr), - the p-value (p_value). """ # Remove NaN values from both series valid_idx = x_series.notna() & y_series.notna() x_clean = x_series[valid_idx] y_clean = y_series[valid_idx] # Compute Pearson correlation corr, p_value = pearsonr(x_clean, y_clean) return corr, p_value def perform_anova(x_series: pd.Series, y_series: pd.Series) -> tuple: """ Performs an ANOVA (analysis of variance) test using Pingouin and returns results. Args: x_series (pd.Series): The categorical series. y_series (pd.Series): The quantitative series. Returns: tuple: A tuple containing: - a DataFrame of the ANOVA results (anova_table), - Eta-squared (eta_squared), - the p-value (p_value). """ # Determine if x_series is categorical if x_series.dtype == 'O' or isinstance(x_series.iloc[0], str): category_column = 'x_series' dependent_column = 'y_series' else: category_column = 'y_series' dependent_column = 'x_series' # Create a temporary DataFrame for the ANOVA analysis df_temp = pd.DataFrame({category_column: x_series, dependent_column: y_series}) # Exclure missing or empty values df_temp = df_temp[df_temp[category_column] != ""].dropna() # unidirectional ANOVA with Pingouin anova_table = pg.anova(data=df_temp, dv=dependent_column, between=category_column, detailed=True) # Compute Eta-squared eta_squared = anova_table["np2"].iloc[0] # Pingouin fournit directement l'eta-carre partiel (np2) # Extraire la p-value p_value = anova_table["p-unc"].iloc[0] return anova_table, eta_squared, p_value ### Chi-Squared Test Function def perform_chi2_test(x_series: pd.Series, y_series: pd.Series, adjusted=True) -> tuple: """ Performs Chi-Squared test and computes Cramer's V (optionally adjusted). Args: x_series (pd.Series): First categorical variable. y_series (pd.Series): Second categorical variable. adjusted (bool): If True (default), returns adjusted Cramer's V. False returns standard V. Returns: tuple: contingency_table, v_retour, p, chi2, dof, expected """ # Create contingency table contingency_table = pd.crosstab(x_series, y_series) # Compute Chi-Squared stats chi2, p, dof, expected = chi2_contingency(contingency_table) # Prepare parameters n = contingency_table.sum().sum() min_dof = min(contingency_table.shape[0]-1, contingency_table.shape[1]-1) k = min(contingency_table.shape) # min(r, c) # Base V (standard) v_standard = np.sqrt(chi2 / (n * min_dof)) if min_dof > 0 else 0 # Adjusted V if adjusted: if k <= 1: v_adj = 0.0 else: v_max = np.sqrt(min_dof / (k - 1)) v_adj = v_standard / v_max if v_max != 0 else 0.0 v_cramer_adj = v_adj else: v_cramer_adj = v_standard return contingency_table, v_cramer_adj, p, chi2, dof, expected def correlation_matrix_with_p_values(df: pd.DataFrame, rotation=90): """ Computes a correlation matrix with statistical significance annotations. - ✔ : p < 0.05 (significant correlation) - ✖ : p ≥ 0.05 (non-significant correlation) Based on the discussion here: https://stackoverflow.com/questions/25571882/pandas-columns-correlation-with-statistical-significance """ # Compute the correlation matrix (rho) rho = df.corr() # Compute the p-value matrix (pval) using pearsonr and adjust for diagonal values pval = df.corr(method=lambda x, y: pearsonr(x, y)[1]) - np.eye(*rho.shape) # Add annotations (✔ for p < 0.05, ✖ otherwise) to each cell p = pval.map(lambda x: '\n✔' if x < 0.05 else '\n✖') # Combine correlation values with significance annotations annotated_matrix = rho.round(2).astype(str) + p # Plot the heatmap with annotations plt.figure(figsize=(12, 10)) sns.heatmap(rho, annot=annotated_matrix, fmt="", cmap='coolwarm', cbar=True, square=True) plt.xticks(rotation=rotation) plt.title('Correlation Matrix with Significance (✔ = p < 0.05, ✖ = p ≥ 0.05)', fontsize=16) plt.show() def eta_squared_with_p_heatmap(df, dependent_var, rotation=90): """ Plots a heatmap of Eta-squared coefficients with p-value annotations. Handles both cases: - dependent_var is quantitative, and other variables are categorical. - dependent_var is categorical, and other variables are quantitative. Explodes list-like categorical columns automatically for proper ANOVA computation. Args: - df : DataFrame containing the dataset. - dependent_var : Name of the dependent variable. - rotation : Rotation angle for variable labels on the X-axis. """ # Helper function to detect if a column is categorical def is_categorical(col): """A column is categorical if it contains only strings or lists of strings.""" return ( df[col].apply(lambda x: isinstance(x, (str, list))).all() ) # Helper to explode list-like columns def explode_if_needed(df, categorical_col): """Explodes a column if it contains list-like elements.""" if df[categorical_col].apply(lambda x: isinstance(x, list)).any(): df = df.explode(categorical_col) return df # Determine if `dependent_var` is categorical or quantitative if is_categorical(dependent_var): dependent_type = "categorical" independent_vars = df.select_dtypes(include=["number"]).columns.tolist() else: dependent_type = "quantitative" independent_vars = df.select_dtypes(include=["category", "object"]).columns.tolist() # Ensure the independent variables exclude the dependent variable independent_vars = [col for col in independent_vars if col != dependent_var] # Containers for results eta_squareds = [] p_values = [] # Iterate over independent variables for col in independent_vars: # Copy the dataframe for processing temp_df = df.copy() # If dependent_var is categorical, check and explode if needed if dependent_type == "categorical": temp_df = explode_if_needed(temp_df, dependent_var) # If the current independent variable is categorical, check and explode if needed elif is_categorical(col): temp_df = explode_if_needed(temp_df, col) # Convert to 'category' type for ANOVA compatibility if temp_df[col].dtype != "category": temp_df[col] = temp_df[col].astype("category") # Perform ANOVA try: if dependent_type == "quantitative": # Quantitative dependent variable -> Categorical independent anova = pg.anova(dv=dependent_var, between=col, data=temp_df, detailed=True) else: # Categorical dependent variable -> Quantitative independent anova = pg.anova(dv=col, between=dependent_var, data=temp_df, detailed=True) # Extract eta-squared and p-value eta_squared = anova.loc[0, "np2"] p_value = anova.loc[0, "p-unc"] except Exception as e: # Skip problematic columns if ANOVA cannot be performed print(f"Skipping column {col} due to error: {e}") eta_squared, p_value = None, None eta_squareds.append(eta_squared) p_values.append(p_value) # Create DataFrame for the heatmap eta_sq_df = pd.DataFrame([eta_squareds], columns=independent_vars) p_value_df = pd.DataFrame([p_values], columns=independent_vars) # Add significance annotations annotations = p_value_df.map(lambda x: "\n✔" if x < 0.05 else "\n✖" if x is not None else "\n-") annotated_matrix = eta_sq_df.round(2).astype(str).replace("nan", "") + annotations # Determine labels based on the nature of `dependent_var` if dependent_type == "quantitative": ylabel = "Quantitative Variable" xlabel = "Categorical Variables" else: ylabel = "Categorical Variable" xlabel = "Quantitative Variables" # Plot the heatmap plt.figure(figsize=(12, 6)) sns.heatmap(eta_sq_df, annot=annotated_matrix, fmt="", cmap="coolwarm", cbar=True, square=False) plt.xticks(rotation=rotation, fontsize=10) plt.yticks([0], [dependent_var], rotation=0, fontsize=12) plt.title("η² Heatmap with Significance (✔ = p < 0.05, ✖ = p ≥ 0.05, - = Error)", fontsize=16) plt.xlabel(xlabel) plt.ylabel(ylabel) plt.show() def cramers_v_heatmap(df: pd.DataFrame, dependent_var: str, rotation: int = 90) -> None: """ Displays a heatmap of adjusted Cramer's V between a dependent column and other categorical features, with sequential list explosion handling. Args: df: Input DataFrame containing categorical data dependent_var: Target column name for association analysis rotation: X-axis label rotation (degrees) """ import pandas as pd import numpy as np import seaborn as sns import matplotlib.pyplot as plt from scipy.stats import chi2_contingency # Duplicate columns check if df.columns.duplicated().any(): dup_cols = df.columns[df.columns.duplicated()].tolist() raise ValueError(f"Duplicate columns detected: {dup_cols}") # Data preparation df_clean = df[[dependent_var] + [c for c in df.columns if c != dependent_var]].copy() numeric_cols = df_clean.select_dtypes(include=np.number).columns df_clean[numeric_cols] = df_clean[numeric_cols].astype(str) # Convert numeric columns to categorical # Results storage results = { 'adj_cramers_v': [], 'p_value': [], 'k_value': [], 'features': [] } # Feature analysis loop for feature in [c for c in df_clean.columns if c != dependent_var]: try: # Sequential explosion with index alignment target_exploded = df_clean[[dependent_var]].explode(dependent_var).dropna() feature_exploded = df_clean[[feature]].explode(feature).dropna() merged_df = target_exploded.merge( feature_exploded, left_index=True, right_index=True, how='inner' ).dropna() if merged_df.empty: print(f"Skipped {feature}: Not enough valid data after explosion") continue # Contingency table creation contingency_table = pd.crosstab(merged_df[dependent_var], merged_df[feature]) # Statistical parameters n_obs = contingency_table.sum().sum() n_rows, n_cols = contingency_table.shape min_degrees = min(n_rows - 1, n_cols - 1) min_dim = min(n_rows, n_cols) # Chi2 test results chi2_stat, p_val, _, _ = chi2_contingency(contingency_table) # Adjusted Cramer's V calculation if min_degrees == 0 or min_dim <= 1: adj_v = 0.0 else: std_v = np.sqrt(chi2_stat / (n_obs * min_degrees)) max_v = np.sqrt(min_degrees / (min_dim - 1)) adj_v = std_v / max_v # Store results results['adj_cramers_v'].append(adj_v) results['p_value'].append(p_val) results['k_value'].append(min_dim) results['features'].append(feature) except Exception as e: print(f"Error processing {feature}: {str(e)}") continue # Heatmap data preparation v_matrix = pd.DataFrame([results['adj_cramers_v']], columns=results['features']) k_matrix = pd.DataFrame([results['k_value']], columns=results['features']) p_matrix = pd.DataFrame([results['p_value']], columns=results['features']) # Annotation formatting annotations = ( v_matrix.round(2).astype(str) + "\n(k=" + k_matrix.astype(str) + ")" + p_matrix.map(lambda p: " ✔" if p < 0.05 else " ✖") ) # Visualization settings plt.figure(figsize=(12, 6)) ax = sns.heatmap( v_matrix, annot=annotations, fmt="", cmap="coolwarm", cbar_kws={'label': 'Adjusted Cramer\'s V'}, linewidths=0.5, annot_kws={"size": 10, "color": "white"} ) plt.title(f"Cramer's V Heatmap Association Strength: {dependent_var} vs Features\n(✔ = p < 0.05, ✖ = p ≥ 0.05)", pad=15) plt.xticks(rotation=rotation) plt.yticks([]) plt.xlabel("Features") plt.show() def pca_analysis( df: pd.DataFrame, n_components: int = 2, normalize: bool = True, rotation: int = 90, figsize: tuple = (14, 8) ) -> None: """ Performs PCA analysis with variance explanation and annotated component loadings. Args: df: Quantitative variables DataFrame n_components: Number of principal components to keep normalize: Whether to standardize variables (default True) rotation: X-axis label rotation figsize: Output figure dimensions """ # Check for duplicates if df.columns.duplicated().any(): dup_cols = df.columns[df.columns.duplicated()].tolist() raise ValueError(f"Duplicate columns detected: {dup_cols}") df_clean = df.dropna() # Standardization if normalize: scaler = StandardScaler() X = scaler.fit_transform(df_clean) else: X = df_clean.values # PCA computation pca = PCA(n_components=n_components) principal_components = pca.fit_transform(X) # Build components dataframe loadings = pca.components_.T * np.sqrt(pca.explained_variance_) components_df = pd.DataFrame( loadings, index=df_clean.columns, columns=[f"PC{i+1}" for i in range(n_components)] ) # Create visualization grid fig = plt.figure(figsize=figsize) gs = fig.add_gridspec(2, 1, height_ratios=[1, 2.5]) # Variance explanation plot ax1 = fig.add_subplot(gs[0]) explained_var = pca.explained_variance_ratio_ cum_var = np.cumsum(explained_var) ax1.plot(range(1, n_components+1), cum_var, 'o-', color='#2e5e87') ax1.set_ylabel('Cumulative Explained Variance', fontsize=10) ax1.set_ylim(0, 1.1) ax1.set_xticks(range(1, n_components+1)) # Annotate variance percentage for i, (ev, cv) in enumerate(zip(explained_var, cum_var)): ax1.text(i+1, cv + 0.03, f"{cv*100:.1f}%\n(cumul.)\n——\n{ev*100:.1f}%", ha='center', fontsize=8, color='#174a6e') # Loadings heatmap ax2 = fig.add_subplot(gs[1]) heatmap = sns.heatmap( components_df.T, annot=components_df.T.round(2), fmt="", cmap='coolwarm', cbar_kws={'label': 'Loading Value'}, linewidths=0.5, ax=ax2 ) # Formatting heatmap.set_yticklabels( [f"PC{i+1}\n({ev*100:.1f}%)" for i, ev in enumerate(explained_var)], rotation=0 ) plt.xticks(rotation=rotation) plt.title(f"PCA Component Loadings (Normalized={normalize})", pad=20, fontsize=14) plt.tight_layout() plt.show() def compute_distances(df, tree_index): """ Computes distances between a specific tree and all other trees in the DataFrame. Args: df: DataFrame containing tree data with 'geo_point_2d_a' (longitude) and 'geo_point_2d_b' (latitude) columns. tree_index: Index of the specific tree in the DataFrame. Returns: A pandas Series containing distances to all other trees from the specified tree. Returns None if the tree_index is out of range or if necessary columns are missing. """ # Safety checks if not all(col in df.columns for col in ['geo_point_2d_a', 'geo_point_2d_b']): print("Error: 'geo_point_2d_a' or 'geo_point_2d_b' columns not found in DataFrame.") return None if tree_index not in df.index: print(f"Error: tree_index {tree_index} is out of range.") return None # Get coordinates of the chosen tree tree_coords = (df.loc[tree_index, 'geo_point_2d_a'], df.loc[tree_index, 'geo_point_2d_b']) # Calculate distances to all other trees distances = [] for index, row in df.iterrows(): other_tree_coords = (row['geo_point_2d_a'], row['geo_point_2d_b']) # Assuming latitude, longitude order distance = geodesic(tree_coords, other_tree_coords).meters distances.append(distance) return pd.Series(distances, index=df.index) # Use original index def format_coefficient(value, limit=5): threshold = 0.01 digits_after_coma = 2 while abs(value) < threshold: threshold /= 10 digits_after_coma += 1 if digits_after_coma > limit: digits_after_coma = 0 # return f'{round(value,digits_after_coma):.{digits_after_coma}f}' return f'{round(value,digits_after_coma)}' # to drop categories inside of my categorical variable each time the count < 10 def drop_infrequent_categories(df, column, threshold=10): """Drops categories from a categorical variable if their count is below a threshold.""" value_counts = df[column].value_counts() categories_to_keep = value_counts[value_counts >= threshold].index df[column] = df[column].astype('string').apply(lambda x: x if x in categories_to_keep else 'Other') return df