this is my code. can you help me make it better? can you define a more professional and efficient structure of the code? can you create some classes instead of just functions?

import csv
import re
import os
import pandas as pd
from collections import defaultdict, Counter
import spacy
import numpy as np
from sklearn.cluster import AgglomerativeClustering
from sklearn.preprocessing import OneHotEncoder
from scipy.spatial.distance import cosine

# ------------------------------#
#           Setup               #
# ------------------------------#

# Load spaCy model
nlp = spacy.load("en_core_web_lg")  # Note: We're using the larger model for better word vectors

# Function to lemmatize a keyword using spaCy
def lemmatize_keyword(keyword):
    doc = nlp(keyword.lower())
    return ' '.join([token.lemma_ for token in doc])

# Function to process the CSV file and lemmatize keywords per row, and output lemmatized counts
def lemmatize_keywords(input_file, lemmatized_counts_output_file):
    # Read the CSV file using pandas
    df = pd.read_csv(input_file)
    
    # Ensure 'Image Name' and 'Keywords' columns exist
    if 'Image Name' not in df.columns or 'Keywords' not in df.columns:
        print("Error: 'Image Name' and 'Keywords' columns not found in input CSV.")
        return None, None
    
    per_row_data = []
    keyword_counts = Counter()
    
    # Iterate through each row to extract and lemmatize keywords
    for index, row in df.iterrows():
        image_name = row.get('Image Name', '')
        keywords_str = row.get('Keywords', '')
        if pd.isna(keywords_str):
            keywords_str = ''
        
        # Split keywords by comma and strip whitespace
        extracted_keywords = [kw.strip() for kw in keywords_str.split(',')]
        # Lemmatize keywords
        lemmatized_keywords = [lemmatize_keyword(kw) for kw in extracted_keywords if kw]
        # Update global counts
        keyword_counts.update(lemmatized_keywords)
        
        # Store per-row data
        per_row_data.append({
            'Image Name': image_name,
            'Keywords': ', '.join(extracted_keywords),
            'Lemmatized Keywords': ', '.join(lemmatized_keywords)
        })
    
    # Create a DataFrame from per_row_data
    per_row_df = pd.DataFrame(per_row_data)
    
    # Write the lemmatized keyword counts to CSV
    lemmatized_counts = [{'Lemmatized Keyword': kw, 'Count': count} 
                         for kw, count in keyword_counts.items()]
    lemmatized_counts_df = pd.DataFrame(lemmatized_counts)
    
    # Sort by Count in descending order
    lemmatized_counts_df = lemmatized_counts_df.sort_values(by='Count', ascending=False)
    
    # Write to CSV
    lemmatized_counts_df.to_csv(lemmatized_counts_output_file, index=False)
    print(f"Lemmatized keyword counts saved to '{lemmatized_counts_output_file}'.")
    
    return per_row_df, keyword_counts

# Function to get word vector using spaCy
def get_word_vector(word):
    return nlp(word).vector

# Function to cluster keywords using spaCy vectors
def cluster_keywords(keywords, num_clusters=5, distance_threshold=None):
    vectors = [get_word_vector(kw) for kw in keywords]
    vectors = np.array(vectors)
    
    # Perform Hierarchical Clustering
    if distance_threshold is not None:
        print(f"Performing Agglomerative Clustering with distance_threshold={distance_threshold}")
        clustering_model = AgglomerativeClustering(n_clusters=None, distance_threshold=distance_threshold)
    else:
        print(f"Performing Agglomerative Clustering with n_clusters={num_clusters}")
        clustering_model = AgglomerativeClustering(n_clusters=num_clusters)
    
    labels = clustering_model.fit_predict(vectors)
    
    # Organize keywords by clusters
    clusters = defaultdict(list)
    for label, keyword in zip(labels, keywords):
        clusters[label].append(keyword)
    
    return clusters

# Function to find the most representative keyword in a cluster
def find_representative_keyword(cluster):
    if len(cluster) == 1:
        return cluster[0]
    
    # Calculate the average vector for the cluster
    avg_vector = np.mean([get_word_vector(word) for word in cluster], axis=0)
    
    # Find the word with the smallest cosine distance to the average vector
    min_distance = float('inf')
    representative = None
    for word in cluster:
        distance = cosine(avg_vector, get_word_vector(word))
        if distance < min_distance:
            min_distance = distance
            representative = word
    
    return representative

# Function to perform clustering with frequency threshold
def perform_clustering_with_threshold(keyword_counts, threshold=5, num_clusters=5, distance_threshold=None):
    # Separate frequent and infrequent keywords
    frequent_keywords = [kw for kw, count in keyword_counts.items() if count > threshold]
    infrequent_keywords = [kw for kw, count in keyword_counts.items() if count <= threshold]
    
    print(f"Number of frequent keywords (> {threshold}): {len(frequent_keywords)}")
    print(f"Number of infrequent keywords (<= {threshold}): {len(infrequent_keywords)}")
    
    # Keep frequent keywords as-is
    clustered = {kw: kw for kw in frequent_keywords}
    
    # Cluster infrequent keywords
    if infrequent_keywords:
        clusters = cluster_keywords(infrequent_keywords, num_clusters, distance_threshold)
        for label, cluster in clusters.items():
            representative = find_representative_keyword(cluster)
            for word in cluster:
                clustered[word] = representative
        print(f"Number of cluster representatives: {len(set(clustered.values()))}")
    else:
        print("No infrequent keywords to cluster.")
    
    return clustered

def generate_clustered_keywords_and_categories(per_row_df, clustered_keyword_mapping, category_keyword_counts, remove_duplicates=False):
    top_keywords_per_category = {category: set(counts.keys()) for category, counts in category_keyword_counts.items()}

    # Initialize lists for categories and counters for "Other"
    categories = ['Object', 'Adjective', 'Activity', 'Place', 'Feeling']
    category_keywords_list = {cat: [] for cat in categories}
    
    clustered_keywords_list = []

    # Initialize a counter for the number of rows with specific "Other" counts
    other_count_rows = {i: 0 for i in range(6)}  # assuming "Other" count ranges from 0 to 5

    for idx, row in per_row_df.iterrows():
        lemmatized_keywords = row['Lemmatized Keywords'].split(', ')
        clustered_keywords = [clustered_keyword_mapping.get(kw, kw) for kw in lemmatized_keywords]
        if remove_duplicates:
            # Remove duplicates while preserving order
            clustered_keywords = list(dict.fromkeys(clustered_keywords))
        clustered_keywords_list.append(', '.join(clustered_keywords))

        # Initialize "Other" counter for each row
        row_other_count = 0

        # Assign keywords to categories based on positions
        for i, category in enumerate(categories):
            if i < len(clustered_keywords):
                keyword = clustered_keywords[i]
                # Map to 'Other' if not in top keywords
                if keyword not in top_keywords_per_category[category]:
                    keyword = 'Other'
                    row_other_count += 1  # Increase the row counter for "Other"
                category_keywords_list[category].append(keyword)
            else:
                category_keywords_list[category].append('')  # or 'Other'
        
        # Count the number of rows with the specific number of "Other" keywords
        if row_other_count <= 5:
            other_count_rows[row_other_count] += 1

        if row_other_count > 0:
            print(f"{row['Image Name']}, {clustered_keywords}, {category_keywords_list['Object'][idx]} {category_keywords_list['Adjective'][idx]} {category_keywords_list['Activity'][idx]} {category_keywords_list['Feeling'][idx]} {category_keywords_list['Place'][idx]}, keywords: {row_other_count}")

    per_row_df['Clustered Keywords'] = clustered_keywords_list

    # Add the category columns to per_row_df
    for category in categories:
        per_row_df[category] = category_keywords_list[category]

    # Print summary of "Other" counts per category
    other_count = {cat: 0 for cat in categories}  # Counter for "Other" per category
    print("\nSummary of 'Other' counts per category:")
    for category, keywords in category_keywords_list.items():
        other_count[category] = sum(1 for keyword in keywords if keyword == 'Other')
        print(f"{category}: {other_count[category]}")

    # Print the summary of rows having specific "Other" counts
    print("\nSummary of 'Other' count rows:")
    for count in range(6):
        print(f"Rows with {count} 'Other' keywords: {other_count_rows[count]}")

    return per_row_df


# Function to categorize and count clustered keywords, and limit to top 50 per category
def categorize_and_count_clustered_keywords(per_row_df, Category_Limit=50):
    category_keyword_counts = {'Object': Counter(), 'Adjective': Counter(), 'Activity': Counter(),
                               'Place': Counter(), 'Feeling': Counter()}
    
    for idx, row in per_row_df.iterrows():
        clustered_keywords = row['Clustered Keywords'].split(', ')
        
        if len(clustered_keywords) > 0:
            category_keyword_counts['Object'][clustered_keywords[0]] +=1
        if len(clustered_keywords) > 1:
            category_keyword_counts['Adjective'][clustered_keywords[1]] +=1
        if len(clustered_keywords) > 2:
            category_keyword_counts['Activity'][clustered_keywords[2]] +=1
        if len(clustered_keywords) > 3:
            category_keyword_counts['Place'][clustered_keywords[3]] +=1
        if len(clustered_keywords) > 4:
            category_keyword_counts['Feeling'][clustered_keywords[4]] +=1
            
    # Limit to top 50 keywords per category
    for category in category_keyword_counts:
        category_keyword_counts[category] = Counter(dict(category_keyword_counts[category].most_common(Category_Limit)))
        
    return category_keyword_counts

# Function to one-hot encode the categorical features
def encode_categorical_features(input_csv_path, output_tsv_path, id_column='Image Name', remove_extension='.jpg', sep='\t', header=False, index=False):
    # Read only the necessary columns to improve efficiency
    columns_to_encode = ['Object', 'Adjective', 'Activity', 'Place', 'Feeling']
    columns_to_read = [id_column] + columns_to_encode
    df = pd.read_csv(input_csv_path, usecols=columns_to_read)

    # Initialize OneHotEncoder with dtype set to integer to output int values
    enc = OneHotEncoder(handle_unknown='ignore', dtype=np.int8)

    # Fit and transform the categorical columns
    emb = enc.fit_transform(df[columns_to_encode])

    # Retrieve the names of the one-hot encoded features
    feature_names = enc.get_feature_names_out(columns_to_encode)

    # Create a DataFrame with the encoded features and ensure they are integers
    emb_df = pd.DataFrame(emb.toarray(), columns=feature_names, dtype=np.int8)

    # Concatenate the id_column with the one-hot encoded features
    result_df = pd.concat([df[[id_column]], emb_df], axis=1)

    # Remove specified extension from id_column
    if remove_extension:
        result_df[id_column] = result_df[id_column].str.replace(remove_extension, '', regex=False)

    # Save the final DataFrame to a TSV file without headers and index
    result_df.to_csv(output_tsv_path, index=index, header=header, sep=sep)

# ------------------------------#
#             Main              #
# ------------------------------#

def main():
    # File paths (replace these with your actual file paths)
    original_input_file = '/home/danilo/Projects/IR_MD/cleaned_/home/danilo/Projects/IR_MD/cleaned_/home/danilo/Projects/IR_MD/TESTING/cleaned_graffe_better_prompt_KeyExt_Qwen2VL7B_20240912-131955_.csv'
    lemmatized_counts_output_file = '/home/danilo/Projects/IR_MD/TESTING/keywords_lemmatized_counts.csv'
    
    # Clustering parameters
    frequency_threshold = 10  # Keywords with count >10 are kept as-is
    num_clusters = 50       # Number of clusters for infrequent keywords (used if distance_threshold is None)
    Category_Limit = 100    # Limit to top 100 keywords top popular per category
    distance_threshold = None  # Hierarchical clustering with a distance threshold
    
    remove_duplicates = False  # Set to True to remove duplicates in clustered keywords per row
    
    file_name = f'spacy_Baby_{Category_Limit}_Categories_{num_clusters}_NumCluster_{frequency_threshold}_FreqThres_{distance_threshold}_DistThresh_clustered'
    try:
        os.makedirs(f"/home/danilo/Projects/IR_MD/TESTING/{file_name}")
    except: pass

    # Step 1: Lemmatization and saving lemmatized counts
    per_row_df, keyword_counts = lemmatize_keywords(original_input_file, lemmatized_counts_output_file)
    if per_row_df is None:
        return  # Exit if there was an error
    
    # Step 2: Perform clustering on infrequent keywords
    clustered_keyword_mapping = perform_clustering_with_threshold(
        keyword_counts, 
        threshold=frequency_threshold, 
        num_clusters=num_clusters, 
        distance_threshold=distance_threshold
    )
    
    # Step 3: Generate 'Clustered Keywords' column
    per_row_df['Clustered Keywords'] = per_row_df['Lemmatized Keywords'].apply(
        lambda kws: ', '.join([clustered_keyword_mapping.get(kw, kw) for kw in kws.split(', ')])
    )
    
    # Step 4: Categorize and count clustered keywords
    category_keyword_counts = categorize_and_count_clustered_keywords(per_row_df, Category_Limit=Category_Limit)
    
    # Step 5: Generate clustered keywords per row and categorize with top keywords only
    per_row_df = generate_clustered_keywords_and_categories(
        per_row_df, clustered_keyword_mapping, category_keyword_counts, remove_duplicates=remove_duplicates
    )
    
    # Print counts for each category
    for category, counts in category_keyword_counts.items():
        print(f"\nCategory: {category}")
        for keyword, count in counts.items():
            print(f"{keyword}: {count}")
    
    # Optionally, write category counts to CSV files
    for category, counts in category_keyword_counts.items():
        category_counts_df = pd.DataFrame({'Keyword': list(counts.keys()), 'Count': list(counts.values())})
        category_counts_df = category_counts_df.sort_values(by='Count', ascending=False)
        category_counts_output_file = f'/home/danilo/Projects/IR_MD/TESTING/{file_name}/{category}_keywords_counts.csv'
        category_counts_df.to_csv(category_counts_output_file, index=False)
        print(f"\n{category} keyword counts saved to '{category_counts_output_file}'.")
    
    # Step 6: Construct the output file name dynamically based on parameters
    output_file = f'/home/danilo/Projects/IR_MD/TESTING/{file_name}/{file_name}.csv'
    
    # Step 7: Write the output CSV
    per_row_df.to_csv(output_file, index=False)
    print(f"\nProcess completed. Output saved to '{output_file}'.")
    print(f"Lemmatized counts saved to '{lemmatized_counts_output_file}'.")
    
    # Step 8: One-hot encode the categorical features
    one_hot_encoded_file = f'/home/danilo/Projects/IR_MD/TESTING/{file_name}/{file_name}_onehot.tsv'
    encode_categorical_features(output_file, one_hot_encoded_file, id_column='Image Name', remove_extension='.jpg', sep='\t', header=False, index=False)
    print(f"\nOne-hot encoded features saved to '{one_hot_encoded_file}'.")

if __name__ == "__main__":
    main()