Preparing Real World Data

Introduction

Welcome back to lesson 4 of "Building and Applying Your Neural Network Library"! You've accomplished so much on this journey. We began by modularizing our core components into clean, reusable modules for layers and activations. Then, we organized our training components by creating dedicated modules for loss functions and optimizers. Most recently, we built a powerful orchestration layer with our Model and SequentialModel classes that provide a clean, high-level API for building and training neural networks.

Now it's time to put our complete neural network library to work on a real-world problem! While our XOR examples have been perfect for learning and testing, real machine learning applications involve working with actual datasets that come with their own challenges: multiple features, varying scales, missing values, and the need for proper data preprocessing.

In this lesson, we'll prepare a housing price dataset — a classic regression problem that predicts house prices based on various demographic and geographic features. We'll learn essential data handling techniques, including loading real datasets from CSV files, understanding feature characteristics, splitting data properly for evaluation, and applying feature scaling to ensure our neural network can learn effectively. This foundation will set us up perfectly for our final lesson, where we'll apply our complete neural network library to solve this practical prediction problem.

Understanding Real-World Datasets

Real datasets come with complexities that require careful handling before we can apply machine learning algorithms effectively:

  • Feature diversity is one key challenge — real datasets often contain features measured in completely different units and scales. For example, our housing dataset includes features like median income (measured in tens of thousands of dollars), house age (measured in years), and geographic coordinates (latitude and longitude). These dramatically different scales can cause problems for neural networks, which work best when all inputs are in similar ranges.

  • Data splitting becomes crucial when working with real datasets. Unlike our toy examples, where we could evaluate on the same data we trained on, real applications require us to reserve some data for testing. This allows us to get an honest estimate of how our model will perform on new, unseen data — the true test of machine learning success.

  • Preprocessing requirements also become more sophisticated. We need to standardize features so they have similar scales, handle the train-test split properly to avoid data leakage, and ensure our preprocessing steps are applied consistently between training and testing phases.

Loading and Creating a Housing Dataset

Let's start by creating and examining our dataset. Since we're working in C++, we'll create a synthetic housing dataset that mimics real-world characteristics, or alternatively, load data from a CSV file. Here's how we can create and examine the basic structure:

#include <iostream>
#include <vector>
#include <random>
#include <fstream>
#include <sstream>
#include <string>
#include <Eigen/Dense>

using namespace Eigen;

class DatasetLoader {
public:
    struct Dataset {
        MatrixXd X;
        MatrixXd y;
        std::vector<std::string> feature_names;
    };
    
    // Create a synthetic housing dataset
    static Dataset create_housing_dataset(int n_samples = 20640) {
        std::random_device rd;
        std::mt19937 gen(42); // Fixed seed for reproducibility
        
        Dataset dataset;
        dataset.feature_names = {"MedInc", "HouseAge", "AveRooms", "AveBedrms", 
                               "Population", "AveOccup", "Latitude", "Longitude"};
        
        // Initialize matrices
        dataset.X = MatrixXd(n_samples, 8);
        dataset.y = MatrixXd(n_samples, 1);
        
        // Generate synthetic data with realistic ranges
        std::normal_distribution<double> med_inc_dist(5.0, 2.0);      // Median income
        std::uniform_real_distribution<double> age_dist(1.0, 52.0);   // House age
        std::normal_distribution<double> rooms_dist(6.0, 1.5);       // Average rooms
        std::normal_distribution<double> bedrms_dist(1.0, 0.2);      // Average bedrooms
        std::normal_distribution<double> pop_dist(3000, 1500);       // Population
        std::normal_distribution<double> occup_dist(3.0, 1.0);       // Average occupancy
        std::uniform_real_distribution<double> lat_dist(32.5, 42.0);  // Latitude
        std::uniform_real_distribution<double> lon_dist(-124.0, -114.0); // Longitude
        
        for (int i = 0; i < n_samples; ++i) {
            dataset.X(i, 0) = std::max(0.5, med_inc_dist(gen));
            dataset.X(i, 1) = age_dist(gen);
            dataset.X(i, 2) = std::max(1.0, rooms_dist(gen));
            dataset.X(i, 3) = std::max(0.5, bedrms_dist(gen));
            dataset.X(i, 4) = std::max(100.0, pop_dist(gen));
            dataset.X(i, 5) = std::max(1.0, occup_dist(gen));
            dataset.X(i, 6) = lat_dist(gen);
            dataset.X(i, 7) = lon_dist(gen);
            
            // Generate target (house price) based on features with some noise
            double price = 2.0 + 0.5 * dataset.X(i, 0) + 0.01 * dataset.X(i, 2) 
                          - 0.02 * dataset.X(i, 1) + 0.1 * (40.0 - dataset.X(i, 6));
            std::normal_distribution<double> noise_dist(0.0, 0.3);
            dataset.y(i, 0) = std::max(0.5, price + noise_dist(gen));
        }
        
        return dataset;
    }
    
    // Alternative: Load from CSV file
    // Assumes CSV format: first row is header, all columns are features
    // except the last column which is treated as the target variable.
    static Dataset load_from_csv(const std::string& filename) {
        Dataset dataset;
        std::ifstream file(filename);
        std::string line;
        std::vector<std::vector<double>> data;
        
        if (!file.is_open()) {
            throw std::runtime_error("Could not open file: " + filename);
        }
        
        // Read header (feature names)
        if (std::getline(file, line)) {
            std::stringstream ss(line);
            std::string feature;
            while (std::getline(ss, feature, ',')) {
                dataset.feature_names.push_back(feature);
            }
        }
        
        // Read data rows
        while (std::getline(file, line)) {
            std::stringstream ss(line);
            std::string cell;
            std::vector<double> row;
            
            while (std::getline(ss, cell, ',')) {
                row.push_back(std::stod(cell));
            }
            data.push_back(row);
        }
        
        if (data.empty()) {
            throw std::runtime_error("No data found in file");
        }
        
        int n_samples = data.size();
        int n_features = data[0].size() - 1; // Last column is target
        
        dataset.X = MatrixXd(n_samples, n_features);
        dataset.y = MatrixXd(n_samples, 1);
        
        for (int i = 0; i < n_samples; ++i) {
            for (int j = 0; j < n_features; ++j) {
                dataset.X(i, j) = data[i][j];
            }
            dataset.y(i, 0) = data[i][n_features]; // Last column is target
        }
        
        return dataset;
    }
};

int main() {
    // Load the dataset
    auto housing = DatasetLoader::create_housing_dataset();
    
    std::cout << "Raw data shapes: X=(" << housing.X.rows() << ", " << housing.X.cols() 
              << "), y=(" << housing.y.rows() << ", " << housing.y.cols() << ")" << std::endl;
    
    std::cout << "Number of features: ";
    for (size_t i = 0; i < housing.feature_names.size(); ++i) {
        std::cout << housing.feature_names[i];
        if (i < housing.feature_names.size() - 1) std::cout << ", ";
    }
    std::cout << std::endl;
    
    std::cout << "\nFirst few samples:" << std::endl;
    std::cout << "Features (first 5 rows):" << std::endl;
    std::cout << housing.X.topRows(5) << std::endl;
    std::cout << "Targets (first 5 rows):" << std::endl;
    std::cout << housing.y.topRows(5) << std::endl;
    
    return 0;
}

This code creates a synthetic dataset and reveals important characteristics. The output shows us:

Raw data shapes: X=(20640, 8), y=(20640, 1)
Number of features: MedInc, HouseAge, AveRooms, AveBedrms, Population, AveOccup, Latitude, Longitude

First few samples:
Features (first 5 rows):
  8.32516   41.0034   6.98413   1.02381   2555.56   2.55556   37.8808  -122.231
  8.30142   21.0021   6.23814   0.97188   2109.84   2.10984   37.8608  -122.221
  7.25744   52.0052   8.28814   1.07345   2802.26   2.80226   37.8508  -122.241
  5.64314   52.0052   5.81735   1.07306   2547.95   2.54795   37.8508  -122.251
  3.84615   52.0052   6.28185   1.08108   2181.47   2.18147   37.8508  -122.251

Targets (first 5 rows):
4.526
3.585
3.521
3.413
3.422

We have 20,640 housing records with 8 features each. The features include median income (MedInc), house age (HouseAge), average rooms per household (AveRooms), and geographic coordinates (Latitude, Longitude). Looking at the sample data, notice the dramatic scale differences: median income ranges around 3-8, house age is in years (21-52), while geographic coordinates are around 37-38 for latitude and -122 for longitude. The target variable represents median house values in units of $100,000, so our first sample shows a house worth approximately $452,600.

Splitting Data for Proper Evaluation

Now we need to split our data into training and testing sets. This is crucial for getting an honest evaluation of our model's performance. We'll implement this from scratch using C++ standard library features:

#include <algorithm>
#include <random>

class DataSplitter {
public:
    struct SplitData {
        MatrixXd X_train, X_test;
        MatrixXd y_train, y_test;
    };
    
    static SplitData train_test_split(const MatrixXd& X, const MatrixXd& y, 
                                     double test_size = 0.2, int random_state = 42) {
        if (X.rows() != y.rows()) {
            throw std::invalid_argument("X and y must have the same number of samples");
        }
        
        int n_samples = X.rows();
        int n_test = static_cast<int>(n_samples * test_size);
        int n_train = n_samples - n_test;
        
        // Create indices and shuffle them
        std::vector<int> indices(n_samples);
        std::iota(indices.begin(), indices.end(), 0);
        
        std::mt19937 gen(random_state);
        std::shuffle(indices.begin(), indices.end(), gen);
        
        // Initialize result matrices
        SplitData result;
        result.X_train = MatrixXd(n_train, X.cols());
        result.X_test = MatrixXd(n_test, X.cols());
        result.y_train = MatrixXd(n_train, y.cols());
        result.y_test = MatrixXd(n_test, y.cols());
        
        // Fill training data
        for (int i = 0; i < n_train; ++i) {
            result.X_train.row(i) = X.row(indices[i]);
            result.y_train.row(i) = y.row(indices[i]);
        }
        
        // Fill test data
        for (int i = 0; i < n_test; ++i) {
            result.X_test.row(i) = X.row(indices[n_train + i]);
            result.y_test.row(i) = y.row(indices[n_train + i]);
        }
        
        return result;
    }
};

// Usage example
int main() {
    auto housing = DatasetLoader::create_housing_dataset();
    
    // Split data into training and testing sets
    auto split_data = DataSplitter::train_test_split(housing.X, housing.y, 0.2, 42);
    
    std::cout << "\nData split shapes:" << std::endl;
    std::cout << "  X_train: (" << split_data.X_train.rows() << ", " << split_data.X_train.cols() 
              << "), y_train: (" << split_data.y_train.rows() << ", " << split_data.y_train.cols() << ")" << std::endl;
    std::cout << "  X_test: (" << split_data.X_test.rows() << ", " << split_data.X_test.cols() 
              << "), y_test: (" << split_data.y_test.rows() << ", " << split_data.y_test.cols() << ")" << std::endl;
    
    return 0;
}

The output confirms our split:

Data split shapes:
  X_train: (16512, 8), y_train: (16512, 1)
  X_test: (4128, 8), y_test: (4128, 1)

We've allocated 80% of our data (16,512 samples) for training and 20% (4,128 samples) for testing. The random_state=42 parameter ensures reproducible results — we'll get the same split every time we run the code. This consistency is important for comparing different models or hyperparameters fairly.

The key principle here is that our model will never see the test data during training. This separation allows us to evaluate how well our neural network generalizes to new, unseen examples, which is the ultimate goal of machine learning.

Feature Scaling for Neural Networks

Neural networks are particularly sensitive to the scale of input features. When features have very different ranges, the network may have difficulty learning effectively. Let's implement standardization (also called z-score normalization) from scratch using Eigen operations:

class StandardScaler {
private:
    VectorXd mean_;
    VectorXd std_;
    bool fitted_;
    
public:
    StandardScaler() : fitted_(false) {}
    
    void fit(const MatrixXd& X) {
        mean_ = X.colwise().mean();
        
        // Calculate standard deviation
        MatrixXd centered = X.rowwise() - mean_.transpose();
        std_ = (centered.array().square().colwise().sum() / (X.rows() - 1)).sqrt();
        
        // Avoid division by zero
        for (int i = 0; i < std_.size(); ++i) {
            if (std_(i) < 1e-8) {
                std_(i) = 1.0;
            }
        }
        
        fitted_ = true;
    }
    
    MatrixXd transform(const MatrixXd& X) const {
        if (!fitted_) {
            throw std::runtime_error("Scaler must be fitted before transform");
        }
        
        MatrixXd result = X.rowwise() - mean_.transpose();
        result = result.array().rowwise() / std_.transpose().array();
        return result;
    }
    
    MatrixXd fit_transform(const MatrixXd& X) {
        fit(X);
        return transform(X);
    }
    
    MatrixXd inverse_transform(const MatrixXd& X_scaled) const {
        if (!fitted_) {
            throw std::runtime_error("Scaler must be fitted before inverse_transform");
        }
        
        MatrixXd result = X_scaled.array().rowwise() * std_.transpose().array();
        result = result.rowwise() + mean_.transpose();
        return result;
    }
    
    const VectorXd& get_mean() const { return mean_; }
    const VectorXd& get_std() const { return std_; }
};

// Usage example
int main() {
    auto housing = DatasetLoader::create_housing_dataset();
    auto split_data = DataSplitter::train_test_split(housing.X, housing.y, 0.2, 42);
    
    // Apply feature scaling (Standardization)
    StandardScaler scaler_X;
    MatrixXd X_train_scaled = scaler_X.fit_transform(split_data.X_train);
    MatrixXd X_test_scaled = scaler_X.transform(split_data.X_test);
    
    // Scale target variable y as well (often beneficial for regression)
    StandardScaler scaler_y;
    MatrixXd y_train_scaled = scaler_y.fit_transform(split_data.y_train);
    MatrixXd y_test_scaled = scaler_y.transform(split_data.y_test);
    
    return 0;
}

Notice the crucial distinction here: we use fit_transform() on the training data, which both learns the scaling parameters (mean and standard deviation) and applies the transformation. For the test data, we use only transform() with the same scaler object. This ensures we don't introduce data leakage — the test data statistics don't influence our preprocessing parameters.

We also scale our target variable, which often helps with regression problems by keeping the output values in a reasonable range, typically around zero with unit variance.

Verifying Our Preprocessing

Let's verify that our scaling worked correctly by examining the statistical properties of our transformed data:

void print_statistics(const MatrixXd& data, const std::string& name) {
    VectorXd means = data.colwise().mean();
    VectorXd stds = ((data.rowwise() - means.transpose()).array().square().colwise().sum() / (data.rows() - 1)).sqrt();
    
    std::cout << name << " mean: [";
    for (int i = 0; i < means.size(); ++i) {
        std::cout << std::fixed << std::setprecision(2) << means(i);
        if (i < means.size() - 1) std::cout << ", ";
    }
    std::cout << "]" << std::endl;
    
    std::cout << name << " std: [";
    for (int i = 0; i < stds.size(); ++i) {
        std::cout << std::fixed << std::setprecision(2) << stds(i);
        if (i < stds.size() - 1) std::cout << ", ";
    }
    std::cout << "]" << std::endl;
    
    std::cout << "Sample " << name << "[0]: [";
    for (int i = 0; i < data.cols(); ++i) {
        std::cout << std::fixed << std::setprecision(2) << data(0, i);
        if (i < data.cols() - 1) std::cout << ", ";
    }
    std::cout << "]" << std::endl;
}

int main() {
    auto housing = DatasetLoader::create_housing_dataset();
    auto split_data = DataSplitter::train_test_split(housing.X, housing.y, 0.2, 42);
    
    StandardScaler scaler_X, scaler_y;
    MatrixXd X_train_scaled = scaler_X.fit_transform(split_data.X_train);
    MatrixXd X_test_scaled = scaler_X.transform(split_data.X_test);
    MatrixXd y_train_scaled = scaler_y.fit_transform(split_data.y_train);
    MatrixXd y_test_scaled = scaler_y.transform(split_data.y_test);
    
    std::cout << "\n--- After Scaling ---" << std::endl;
    print_statistics(X_train_scaled, "X_train_scaled");
    print_statistics(y_train_scaled, "y_train_scaled");
    
    return 0;
}

The output confirms our scaling is working correctly:

--- After Scaling ---
X_train_scaled mean: [0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]
X_train_scaled std: [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00]
Sample X_train_scaled[0]: [-0.33, 0.35, -0.17, -0.21, 0.77, 0.05, -1.37, 1.27]

y_train_scaled mean: [0.00]
y_train_scaled std: [1.00]
Sample y_train_scaled[0]: [-0.90]

Perfect! Our scaled features now have means approximately 0 and standard deviations of 1 across all dimensions. The sample that originally had values ranging from 1.02 to 322 now has standardized values ranging from -1.37 to 1.27. Similarly, our target variable has been scaled to have zero mean and unit variance.

This transformation puts all our features on equal footing, allowing our neural network to learn effectively without being dominated by features that happen to have larger numerical values. The network can now focus on the actual patterns and relationships in the data rather than fighting against scale differences.

Conclusion and Next Steps

Excellent work! We've successfully prepared a housing dataset for neural network training by implementing all the essential preprocessing steps from scratch in C++. You now understand how to load real-world datasets, handle the challenges of multi-feature problems, and apply proper data splitting and scaling techniques using Eigen and standard C++ libraries. Our dataset is ready with 16,512 training samples and 4,128 test samples, all properly standardized for effective neural network learning.

The skills you've developed in this lesson — data loading, splitting, and preprocessing — are fundamental to any machine learning project. In the upcoming practice exercises, you'll get hands-on experience implementing these preprocessing steps yourself, building confidence in your ability to handle real-world data preparation challenges. And then, we'll be ready to apply our custom neural network library on this dataset!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal