Real databases mix numeric, categorical, ordinal, and binary fields. Copulas let you synthesize them together without flattening the relationships that matter.
Most synthetic data tutorials fail the same way. They get the columns right and the relationships wrong.
You can generate realistic income values, valid country codes, plausible age distributions, and even balanced class labels. But if you synthesize each column independently, the resulting database will still be wrong in the way that matters most to machine learning. The model does not learn from single columns. It learns from the way those columns move together.
I learned this the hard way while testing a risk model built on mixed-type data. The synthetic dataset looked good in isolation. Income histograms matched. Credit score ranges looked realistic. Categorical values were valid. Yet the model trained on it performed suspiciously well in validation and collapsed in production. The reason was simple. The generator had preserved the marginal distributions but destroyed the correlations between numeric, binary, and categorical columns. The synthetic users had become statistically plausible strangers.
That is the problem copulas solve.
Copulas let you separate marginals from dependence structure. In plain language, each column can keep its own distribution, while the copula controls how the columns relate to each other. That makes them especially useful for mixed-type enterprise datasets, where one table may contain income, segment, churn status, tenure, and region all at once.
Why mixed data is hard?
Mixed-type data breaks simple generators because different column types behave differently.
Continuous columns like income and transaction amount have numeric distributions.
Binary columns like churned or defaulted have probabilities.
Ordinal columns like rating or risk band have ordered categories.
Nominal columns like region or product_type have categories with no natural ordering.
If you try to model each one separately, you can preserve the shape of each column but lose the structure across columns. For example, you might generate a dataset where high-income customers are not more likely to have premium products, or where older customers are no more likely to have long tenure. Those relationships are often the signal your model depends on. Copulas help because they model the joint dependency separately from the marginals. That means you can keep your actual column distributions, then couple them through a latent correlation matrix.
A practical view of copulas
For engineers, the simplest mental model is this: Convert each column into a uniform scale using its empirical distribution.
Map those uniform values into a latent Gaussian space.
Learn a correlation matrix in that latent space.
Sample new points from the latent multivariate Gaussian.
Transform the samples back into the original column types.
This is why Gaussian copulas are popular for mixed data. They let you preserve pairwise structure while remaining flexible about the individual column distributions. Recent work continues to use Gaussian copula models for mixed-type correlation estimation because they perform well on heterogeneous data.
Step 1: Build a mixed-type dataset We will generate a simple synthetic enterprise dataset with numeric, binary, ordinal, and categorical columns.
python
import numpy as np
import pandas as pd
np.random.seed(42)
def create_mixed_dataset(n=5000):
income = np.random.lognormal(mean=10.5, sigma=0.6, size=n)
tenure_years = np.clip(np.random.normal(4.5, 2.0, size=n), 0, 15)
age = np.clip(np.random.normal(38, 12, size=n), 18, 80)
region = np.random.choice(
[‘north’, ‘south’, ‘east’, ‘west’],
size=n,
p=[0.3, 0.25, 0.25, 0.2]
)
segment = np.random.choice(
[‘retail’, ‘sme’, ‘enterprise’],
size=n,
p=[0.65, 0.25, 0.10]
)
churn_prob = (
0.18
-
0.000002 * income
-
0.02 * tenure_years
+ 0.002 * (age < 30).astype(int)
)
churn_prob = np.clip(churn_prob, 0.02, 0.85)
churned = (np.random.rand(n) < churn_prob).astype(int)
risk_score = 0.7 * (income < np.median(income)).astype(int) + 0.3 * (tenure_years < 3).astype(int)
risk_band = pd.cut(
risk_score,
bins=[-0.1, 0.25, 0.6, 1.1],
labels=[‘low’, ‘medium’, ‘high’]
).astype(str)
return pd.DataFrame({
‘income’: income,
‘tenure_years’: tenure_years,
‘age’: age,
‘region’: region,
‘segment’: segment,
‘risk_band’: risk_band,
‘churned’: churned
})
df = create_mixed_dataset()
print(df.head())
Step 2: Why independent synthesis fails
If you generate each column separately, you can preserve marginal distributions but lose dependence. This is the classic failure mode. python
def naive_independent_synthesis(df, n=5000): “””
Sample each column independently from its marginal distribution.
This preserves univariate stats but destroys correlations.
“””
synth = pd.DataFrame()
for col in [‘income’, ‘tenure_years’, ‘age’]:
synth[col] = np.random.choice(df[col].values, size=n, replace=True)
for col in [‘region’, ‘segment’, ‘risk_band’, ‘churned’]:
synth[col] = np.random.choice(df[col].values, size=n, replace=True)
return synth
naive_df = naive_independent_synthesis(df)
print(naive_df.head())
This dataset will look fine at first glance. But income may no longer relate to segment, tenure_years may no longer relate to churned, and the model will learn a broken world.
Step 3: Compare relationship preservation
For engineers, the easiest useful test is to compare correlation structure before and after synthesis. python
from scipy.stats import spearmanr
def correlation_matrix_numeric(data, cols):
return data[cols].corr(method=’spearman’)
numeric_cols = [‘income’, ‘tenure_years’, ‘age’, ‘churned’]
real_corr = correlation_matrix_numeric(df, numeric_cols)
naive_corr = correlation_matrix_numeric(naive_df, numeric_cols)
diff = (real_corr — naive_corr).abs()
print(“Real correlations:”)
print(real_corr.round(3))
print(“\nNaive synthetic correlations:”)
print(naive_corr.round(3))
print(“\nAbsolute difference:”)
print(diff.round(3))
If the synthesis is independent, the correlation matrix will drift sharply toward zero. That is the signal that the generator is not preserving relationships.
Step 4: Estimate latent dependence with Gaussian copula
A full copula generator usually starts by mapping each variable to a latent normal space. For a practical engineering approximation, you can transform numeric columns to normal scores and estimate a latent correlation matrix.
python
from scipy.stats import norm, rankdata
def rank_to_normal(x):
r = rankdata(x, method=’average’)
u = (r — 0.5) / len(x)
return norm.ppf(u)
def latent_gaussian_matrix(df, cols):
latent = pd.DataFrame({col: rank_to_normal(df[col].values) for col in cols})
return latent.corr()
latent_cols = [‘income’, ‘tenure_years’, ‘age’]
latent_corr = latent_gaussian_matrix(df, latent_cols)
print(“Latent Gaussian correlation matrix:”)
print(latent_corr.round(3))
This is the part copulas formalize. You estimate dependence in a space where all variables are comparable. That is why copulas work better than raw Pearson correlations on mixed data.
Step 5: Mixed-type preservation strategy
For production engineering, you usually need a hybrid approach:
Use the copula to preserve the continuous-continuous relationships.
Model categorical and ordinal variables conditionally on latent variables.
Reconstruct binary outcomes from calibrated thresholds.
This is not a toy trick. It is the standard practical pattern in mixed data synthesis and imputation work. Latent Gaussian copula models are designed exactly for these mixed settings.
Here is a simple conditional sampling pattern that keeps segment linked to income.
python
def conditional_segment_from_income(income):
if income > 80000:
return np.random.choice([‘sme’, ‘enterprise’], p=[0.4, 0.6])
elif income > 30000:
return np.random.choice([‘retail’, ‘sme’], p=[0.7, 0.3])
else:
return np.random.choice([‘retail’, ‘sme’], p=[0.85, 0.15])
def copula_aware_synthetic(df, n=5000):
synth = pd.DataFrame()
synth[‘income’] = np.random.choice(df[‘income’].values, size=n, replace=True)
synth[‘tenure_years’] = np.random.choice(df[‘tenure_years’].values, size=n, replace=True)
synth[‘age’] = np.random.choice(df[‘age’].values, size=n, replace=True)
synth[‘segment’] = synth[‘income’].apply(conditional_segment_from_income)
synth[‘region’] = np.random.choice(df[‘region’].values, size=n, replace=True)
risk_score = (
(synth[‘income’] < df[‘income’].median()).astype(int)
+ (synth[‘tenure_years’] < 3).astype(int)
)
synth[‘risk_band’] = pd.cut(
risk_score,
bins=[-0.1, 0.5, 1.2, 2.1],
labels=[‘low’, ‘medium’, ‘high’]
).astype(str)
churn_prob = (
0.18
- 0.000002 * synth[‘income’]
- 0.02 * synth[‘tenure_years’]
+ 0.002 * (synth[‘age’] < 30).astype(int)
)
churn_prob = np.clip(churn_prob, 0.02, 0.85)
synth[‘churned’] = (np.random.rand(n) < churn_prob).astype(int)
return synth
copula_like_df = copula_aware_synthetic(df)
print(copula_like_df.head())
Step 6: Check whether relationships survive
Now compare whether the relationship between income and segment survives.
python
def summarize_relationships(real_df, synth_df):
print(“Average income by segment — real:”)
print(real_df.groupby(‘segment’)[‘income’].mean().round(2))
print(“\nAverage income by segment — synthetic:”)
print(synth_df.groupby(‘segment’)[‘income’].mean().round(2))
print(“\nChurn rate by risk band — real:”)
print(real_df.groupby(‘risk_band’)[‘churned’].mean().round(3))
print(“\nChurn rate by risk band — synthetic:”)
print(synth_df.groupby(‘risk_band’)[‘churned’].mean().round(3))
summarize_relationships(df, copula_like_df)
If the synthesis is working, you should see the same monotonic patterns. Higher income segments should generally have higher mean income, and higher risk bands should generally show higher churn. What engineers should watch for
Copulas are powerful, but they are not magic.
They are strongest when:
You have several continuous or ordinal columns.
Preserving pairwise dependence matters more than exact microstructure.
You need synthetic data for testing, privacy, or data augmentation.
They are weaker when:
The data has highly complex multimodal structure.
There are many categorical variables with sparse levels.
Relationships are nonlinear in ways the latent Gaussian space does not capture well.
That is why newer mixed-data approaches extend beyond vanilla Gaussian copulas to Bayesian mixtures, latent factor copulas, and nonparametric variants. These methods aim to preserve more complex mixed-type dependencies while still remaining usable for inference and imputation.
A practical workflow
If you are building synthetic mixed-type databases, use this workflow: Identify the columns whose relationships matter to model performance.
Choose a copula-based method for the continuous and ordinal backbone.
Condition categorical columns on latent variables or important anchors.
Validate marginal distributions separately.
Validate dependence structure with correlation, rank correlation, and downstream TSTR tests.
Add business-rule constraints after synthesis, not before.
That workflow is more robust than treating each column independently and hoping the correlations emerge on their own. They will not.
The bottom line
Copulas are useful because they solve the exact problem engineers care about: preserving cross-column relationships in data that is not all the same type.
If your synthetic database has to keep income aligned with segment, age aligned with churn, and risk band aligned with tenure, you need more than a column generator. You need a dependence model. That is what copulas provide.
For engineering teams, the real lesson is simple: if the marginals look right but the correlations are wrong, the synthetic data is still wrong. Copulas give you a way to keep both.
Copulas for Engineers: Preserving Correlations Across Mixed-Type Columns was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.