Repository: gherka/exhibit (language: python)

Issue:
Fixed errors related to updating Numpy to 2.x



Discussion / hints:

Fix patch:
diff --git a/.travis.yml b/.travis.yml
index 9fb7af9..d71e923 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,12 +1,12 @@
 language: python
 dist: focal
 python:
- - "3.9"
+ - "3.12"
 install:
- - pip install -r requirements.txt
+ - pip install .
  - pip install coverage
- - pip install coveralls
+ - pip install codecov
 script: 
  - coverage run --source=exhibit -m unittest discover
 after_success:
- - coveralls
\ No newline at end of file
+ - codecov
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 166ce93..ea60def 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 ## Release notes
 ---
+
+### 0.9.9 (September 9, 2024)
+Minor version release updating dependencies.
+
+##### Notable version upgrades
+- Default Python version used in automated tests is changed to 3.12
+- Pandas updated to 2.2.2 version
+- SQLAlchemy updated to 2.x API
+- Numpy updated to 2.x API
+
 ### 0.9.8 (November 20, 2023)
 
 ##### Enhancements
diff --git a/README.md b/README.md
index adb2a68..09d0487 100644
--- a/README.md
+++ b/README.md
@@ -1,67 +1,83 @@
-[![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) [![Build Status](https://travis-ci.com/gherka/exhibit.svg?branch=master)](https://travis-ci.com/gherka/exhibit) [![CodeQL](https://github.com/gherka/exhibit/actions/workflows/codeql.yml/badge.svg)](https://github.com/gherka/exhibit/actions/workflows/codeql.yml) [![Coverage Status](https://coveralls.io/repos/github/gherka/exhibit/badge.svg?branch=master)](https://coveralls.io/github/gherka/exhibit?branch=master)
-
----
-## Exhibit: Command line tool to create anonymised demonstrator data
----
-
-The goal of Exhibit is to make it easier to generate anonymised data at scale in a controlled and reproducible way.
-
-**Key features**:
-
-- Control all aspects of the anonymisation process: which columns to anonymise and to what degree
-- Rapidly iterate on the anonymisation options
-- Set categorical weights to create custom distributions
-- Use regular expressions to bulk-anonymise identifiers
-- Add columns derived from newly anonymised data
-- Preserve important relationships between your columns (paired, hierarchical, custom)
-- Add outliers to any subset of the generated data
-- Generate and manipulate missing data and timeseries
-- Generate geo-spatial data using H3 hexes
-- Augment your synthetic data with compiled machine learning models and custom functions
-- Use SQL to generate conditional values based on external tables
-
----
-### Installation:
-
-To install using pip, enter the following command at a Bash or Windows command prompt:
-
-`pip install exhibit`
-
-Alternatively, download or clone the repository and run `pip install .` from the root folder.
-
----
-### Quickstart
-
-Exhibit has two principal modes of operation: 
- - `fromdata` produces a detailed, user-editable `.yml` specification
- - `fromspec` which produces the anonymised dataset from the supplied specification
-
-See the `-h` listing for the full list of optional command line parameters.
-
-The repository includes a few sample datasets and specifications.\
-You can find them in `exhibit/sample/_data` and `exhibit/sample/_spec`
-
-To create a demo dataset, run:\
-`exhibit fromspec exhibit/sample/_spec/inpatients_demo.yml -o demo.csv`
-
-To create a demo specification that equialises all probabilities and weights, run:\
-`exhibit fromdata exhibit/sample/_data/inpatients.csv -ew -o demo.yml`
-
----
-### Database
-
-Exhibit is bundled with a SQLite3 database and a Python utility tool to interact with it. Alternatively, you can connect directly to `/exhbit/db/exhibit.db`. The database contains three sample aliasing datasets: `mountains`, `birds` and `patients` designed to help you quickly alias original values without manually editing individual column values.
-
- - `mountains` has 15 mountain ranges and their top 10 peaks making it useful for aliasing hierarchical pairs, like NHS Boards and Hospitals.
- - `birds` has 150 pairs of common / scientific bird names. This can be useful for 1:1 paired columns.
- - `patients` has 360 made-up patient records with details such as gender, 5-year age band, date of birth and CHI number. Fields from this dataset can be selectively pulled in when linked data is required.
-  - `dates` has dates ranging from 1900-01-01 to 2100-01-01 at a single day interval. This table is useful if you have a SQL statement in the `anonymising_set` that picks dates based on a condition. 
-
-The database is also used to store temporary data for columns where the number of unique values exceeds user threshold and thus not available for editing directly in the `yml` file.
-
-**Note that original, confidential data might be saved in the `exhibit/db/exhibit.db` file on your local machine. You can purge all temporary tables by calling `--purge` command from the included utility tool or by interfacing with the database directly.**
-
----
-### Disclaimer
-
-Please note that the degree of anonymisation for each dataset produced by the tool will depend heavily on user choices in the specification. As such, there is no guarantee that confidential data will be suitably masked under all scenarios. If you intend to work with sensitive data, make sure to thoroughly evaluate the output before making it public.
+## Exhibit: Command line tool to create anonymised demonstrator data
+
+The goal of Exhibit is to make it easier to generate synthethic data at scale in a controlled and reproducible way.
+
+---
+
+<table>
+<!-- Main branch badges -->
+<tr>
+<td style="text-align: left"><code>main</code></td>
+<td style="text-align: left; vertical-align: middle; padding: 4px; line-height: 1">
+<p style="margin:0">
+<a href="https://travis-ci.org/gherka/exhibit"><img src="https://travis-ci.org/gherka/exhibit.svg?branch=main" alt="Build Status"> <a href="https://github.com/gherka/exhibit/actions/workflows/codeql.yml"><img src="https://github.com/gherka/exhibit/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"> <a href="https://codecov.io/github/gherka/exhibit"><img src="https://codecov.io/github/gherka/exhibit/branch/main/graph/badge.svg?token=A5YQIV56QM" alt="codecov">
+</p>
+</td>
+</tr>
+<!-- Latest branch badges -->
+<tr>
+<td style="text-align: left;"><code>latest</code></td>
+<td style="text-align: left; vertical-align: middle; padding: 4px; line-height: 1">
+<p style="margin:0">
+<a href="https://travis-ci.org/gherka/exhibit"><img src="https://travis-ci.org/gherka/exhibit.svg?branch=latest" alt="Build Status"> <a href="https://github.com/gherka/exhibit/actions/workflows/codeql.yml"><img src="https://github.com/gherka/exhibit/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"> <a href="https://codecov.io/github/gherka/exhibit"><img src="https://codecov.io/github/gherka/exhibit/branch/latest/graph/badge.svg?token=A5YQIV56QM" alt="codecov">
+</p>
+</td>
+</tr>
+
+</table>
+
+## Key features
+
+- Control all aspects of the anonymisation process: which columns to anonymise and to what degree
+- Rapidly iterate on the anonymisation options
+- Set categorical weights to create custom distributions
+- Use regular expressions to bulk-anonymise identifiers
+- Add columns derived from newly anonymised data
+- Preserve important relationships between your columns (paired, hierarchical, custom)
+- Add outliers to any subset of the generated data
+- Generate and manipulate missing data and timeseries
+- Generate geo-spatial data using H3 hexes
+- Augment your synthetic data with compiled machine learning models and custom functions
+- Use SQL to generate conditional values based on external tables
+
+## Installation:
+
+To install using pip, enter the following command at a Bash or Windows command prompt:
+
+`pip install exhibit`
+
+Alternatively, download or clone the repository and run `pip install .` from the root folder.
+
+## Quickstart
+
+Exhibit has two principal modes of operation: 
+ - `fromdata` produces a detailed, user-editable `.yml` specification
+ - `fromspec` which produces the anonymised dataset from the supplied specification
+
+See the `-h` listing for the full list of optional command line parameters.
+
+The repository includes a few sample datasets and specifications.\
+You can find them in `exhibit/sample/_data` and `exhibit/sample/_spec`
+
+To create a demo dataset, run:\
+`exhibit fromspec exhibit/sample/_spec/inpatients_demo.yml -o demo.csv`
+
+To create a demo specification that equialises all probabilities and weights, run:\
+`exhibit fromdata exhibit/sample/_data/inpatients.csv -ew -o demo.yml`
+
+## Database
+
+Exhibit is bundled with a SQLite3 database and a Python utility tool to interact with it. Alternatively, you can connect directly to `/exhbit/db/exhibit.db`. The database contains three sample aliasing datasets: `mountains`, `birds` and `patients` designed to help you quickly alias original values without manually editing individual column values.
+
+ - `mountains` has 15 mountain ranges and their top 10 peaks making it useful for aliasing hierarchical pairs, like NHS Boards and Hospitals.
+ - `birds` has 150 pairs of common / scientific bird names. This can be useful for 1:1 paired columns.
+ - `patients` has 360 made-up patient records with details such as gender, 5-year age band, date of birth and CHI number. Fields from this dataset can be selectively pulled in when linked data is required.
+  - `dates` has dates ranging from 1900-01-01 to 2100-01-01 at a single day interval. This table is useful if you have a SQL statement in the `anonymising_set` that picks dates based on a condition. 
+
+The database is also used to store temporary data for columns where the number of unique values exceeds user threshold and thus not available for editing directly in the `yml` file.
+
+**Note that original, confidential data might be saved in the `exhibit/db/exhibit.db` file on your local machine. You can purge all temporary tables by calling `--purge` command from the included utility tool or by interfacing with the database directly.**
+
+## Disclaimer
+
+Please note that the degree of anonymisation for each dataset produced by the tool will depend heavily on user choices in the specification. As such, there is no guarantee that confidential data will be suitably masked under all scenarios. If you intend to work with sensitive data, make sure to thoroughly evaluate the output before making it public.
diff --git a/docs/Scaling of numerical variables.ipynb b/docs/Scaling of numerical variables.ipynb
index a8a912e..86e94e9 100644
--- a/docs/Scaling of numerical variables.ipynb
+++ b/docs/Scaling of numerical variables.ipynb
@@ -1,764 +1,764 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "id": "0c41daba-6432-4378-98e6-54cde5b9f03b",
-   "metadata": {},
-   "source": [
-    "### Background notes"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "d5ca10d1-632d-4e50-ba45-4e4ffba47575",
-   "metadata": {},
-   "source": [
-    "In `exhibit` you can generate numerical values either from a uniform random distribution or from a normal distribution. These values can then be coerced to either floats or integers (as a hack to get discrete values like 0 and 1). It's unlikely you'd want to use generated values as is, so we apply linear scaling before returning the values to the user.\n",
-    "\n",
-    "To draw from a uniform distribution all we need is a starting value and the optional dispersion (noise) percentage to shift the final value around. This uniform value is still affected by feature weights, but with dispersion set to 0 you can get consistent values for the same row values. \n",
-    "\n",
-    "For normal distribution, you'd need the mean and the standard deviation. The two statistics, however, will change by the time you finish generating the dataset because for each row, the weights will affect the mean. Thus, you can use them as initial values, but will have to rescale at the end if you want to keep the original values. If the mean / std are commented out in the spec (user intending to scale to range, for example), then we'll use default mean of 1 (having it as zero will negate mean shifting by weights) and standard deviation of 1.\n",
-    "\n",
-    "Additionally, if the generated distribution has negative values, we will shift the whole set to be in the positive territory if the scaling is set to `target_sum`; other scaling options can be used with negative ranges / statistics.\n",
-    "\n",
-    "The scaling options are:\n",
-    "\n",
-    " - target range\n",
-    " - target statistic (`sum`, `mean`, `std`)\n",
-    " \n",
-    "The goal of the scaling options is to match user expectations and to preseve the shape of the data. Unfortunately, you can't have all matching statistics so by choosing to align to a certain metric, you leave others free-floating. This is so that we don't lose information from the weights and preseve the shape of the data.\n",
-    "\n",
-    "| scaling | min / max | sum | mean | standard deviation | weights |\n",
-    "| :- | :- | :- | :- | :- | :- |\n",
-    "| target range | preserved | free-floating | free-floating | free-floating | __*__ |\n",
-    "| target statistic (sum) | free-floating | preserved | free-floating | free-floating | preserved |\n",
-    "| target statistic (mean + std) | free-floating | free-floating | preserved | preserved | preserved |\n",
-    "\n",
-    "__\\*__ _if the ratio between target_min and target_max doesn't match the generated min and max, either the weights will change\n",
-    "or one of the target ranges will need to be adjusted. For example, assume you have 4 values influencing the weights - `0.05, 0.15, 0.3, 0.4`. You want to scale your generated data to between 10 and 150. Without scaling, the generated data can have values like `25, 75, 150, 200`. Now, the ratio `0.3 / 0.15` is the same as `150 / 75`. However, if we scale to between 10 and 150, we get `10, 50, 110, 150`. And here, we're dealing with intervals - `0.1 = 40` so values with weights 0.15 and 0.3 are separated by 60. Although this is not particularly intuitive, it's still preferable over adjusting the `target_max` like so `target_max = (target_min * generated.max()) / generated.min()` because often you have to have fixed min-max ranges and also adjusting the maximum in this way can lead to unexpected results, like pushing the maximum into the negative territory._\n",
-    "\n",
-    "When generating the specification from the original data, we populate all potential fields: `min`, `max`, `sum`, etc. Rather than creating separate functions for different statistics, we'll try to make sense of what we're given and issue a warning if an impossible or conflicting situation is given.\n",
-    "\n",
-    "For example:\n",
-    "\n",
-    "| min | max | sum | mean | standard deviation | outcome |\n",
-    "| :- | :- | :- | :- | :- |:- |\n",
-    "| given | missing | missing | missing | missing | derive the missing end of the range\n",
-    "| given | given | missing | missing | missing | valid\n",
-    "| given | given | given | given | given | warning - use sum as default\n",
-    "| missing | missing | given | given | given | warning - use sum as default\n",
-    "| missing | missing | missing | given | given | scale to both\n"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "5c1ec07e-d3df-4bd3-a3d8-b7cee08294c0",
-   "metadata": {},
-   "source": [
-    "#### Scaling functions\n",
-    "Scaling floats works as expected. However, as soon as you ask for discrete values, we're at the mercy of rounding which is OK in the large registers, but will produce highly imbalanced datasets when values are low or when standard deviation is small. We try to compensate for it in both scaling to range and scaling to target sum, but if we only have mean / standard deviation to work with, the results will likely be quite imprecise."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 1,
-   "id": "c5386e8b-ca06-46f4-b55f-d42501f5917f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import pandas as pd\n",
-    "import numpy as np\n",
-    "import matplotlib.pyplot as plt"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 2,
-   "id": "cf4670b6-12dd-4a23-b1bd-61557ca96084",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def scale_to_mean_std(array, target_mean=100, target_std=20, discrete=False):\n",
-    "    \n",
-    "    arr = np.array(array)\n",
-    "    \n",
-    "    result = target_mean + (arr - arr.mean()) * target_std / arr.std()\n",
-    "    \n",
-    "    if discrete:\n",
-    "        result = result.round()\n",
-    "    \n",
-    "    return result"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 3,
-   "id": "fa835db8-eeed-4eec-9a07-a143098151f4",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def scale_to_range(array, target_min=None, target_max=None, discrete=False):\n",
-    "    \n",
-    "    X = np.array(array)\n",
-    "    \n",
-    "    # adjust for potential negative signs!\n",
-    "    if not target_min:\n",
-    "\n",
-    "        target_min = target_max - (abs(target_max) - abs(target_max * X.min() / X.max()))\n",
-    "    \n",
-    "    if not target_max:\n",
-    "\n",
-    "        target_max = target_min + abs(target_min * X.max() / X.min()) - abs(target_min)\n",
-    "\n",
-    "    if discrete:\n",
-    "\n",
-    "        target_range = int(np.ceil(target_max) - np.floor(target_min))\n",
-    "        bins = np.linspace(X.min(), X.max(), target_range + 2)\n",
-    "        labels = np.arange(np.floor(target_min), np.ceil(target_max) + 1)\n",
-    "\n",
-    "        result = pd.cut(X, bins=bins, right=True, include_lowest=True, labels=labels).to_numpy()\n",
-    "        \n",
-    "        return result\n",
-    "    \n",
-    "    result = (X - X.min()) / (X.max() - X.min()) * (target_max - target_min) + target_min\n",
-    "\n",
-    "    return result"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 4,
-   "id": "4d0823e7-a911-402e-b458-f2fecf879df0",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def scale_to_sum(array, target_sum=200, discrete=False):\n",
-    "    \n",
-    "    series = pd.Series(array)\n",
-    "    \n",
-    "    if any(series < 0):\n",
-    "        series = series + abs(series.min())\n",
-    "    \n",
-    "    scaling_factor = target_sum / series.dropna().sum()\n",
-    "    scaled_series = series * scaling_factor\n",
-    "    \n",
-    "    if discrete:\n",
-    "        \n",
-    "        row_diff = (target_sum - series.dropna().sum()) / len(series.dropna())\n",
-    "        values = pd.Series(\n",
-    "            np.where(\n",
-    "                series + row_diff >= 0,\n",
-    "                series + row_diff,\n",
-    "                np.where(pd.isnull(series), np.NaN, 0)\n",
-    "                )\n",
-    "        )\n",
-    "    \n",
-    "        #how many rows will need to be rounded up to get to target\n",
-    "        boundary = int(target_sum - np.floor(values).sum())\n",
-    "\n",
-    "        #because values are limited at the lower end at zero, sometimes it's not possible\n",
-    "        #to adjust them to a lower target_sum; we floor them and return\n",
-    "        if boundary < 0:\n",
-    "            return pd.Series(np.floor(values)).to_numpy()\n",
-    "\n",
-    "        #if series has NAs, then the calcualtion will be off\n",
-    "        clean_values = values.dropna() #keep original index\n",
-    "\n",
-    "        #np.ceil and floor return Series so index is preserved\n",
-    "        values.update(np.maximum(np.ceil(clean_values.iloc[0:boundary]), 1))\n",
-    "        values.update(np.floor(clean_values.iloc[boundary:]))\n",
-    "\n",
-    "        #return a series of ints or cast to float if there are any NAs\n",
-    "        #see https://github.com/pandas-dev/pandas/issues/29618\n",
-    "        #before migrating to the new Pandas null-aware int dtype\n",
-    "        scaled_series = values if values.isna().any() else values.astype(int)\n",
-    "    \n",
-    "    return scaled_series"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 5,
-   "id": "176afd13-c1a2-42ca-818b-fe36fb80b31a",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def transform_datasets(datasets, func, **kwargs):\n",
-    "\n",
-    "    new_datasets = {}\n",
-    "\n",
-    "    for i, dataset in datasets.items():\n",
-    "        new_datasets[i] = (\n",
-    "            func(dataset[0], **kwargs),\n",
-    "            func(dataset[1], **kwargs)\n",
-    "        )\n",
-    "    \n",
-    "    return new_datasets"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 6,
-   "id": "1a0ca1a9-2f58-4d62-8d28-a5ee7715bce0",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def plot_original_quartet(datasets):\n",
-    "    '''\n",
-    "    From Matplotlib gallery: https://matplotlib.org/stable/gallery/specialty_plots/anscombe.html\n",
-    "    '''\n",
-    "\n",
-    "    fig, axs = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(6, 6),\n",
-    "                            gridspec_kw={'wspace': 0.08, 'hspace': 0.08})\n",
-    "            \n",
-    "    for ax, (label, (x, y)) in zip(axs.flat, datasets.items()):\n",
-    "        ax.text(0.1, 0.9, label, fontsize=20, transform=ax.transAxes, va='top')\n",
-    "        ax.tick_params(direction='in', top=True, right=True)\n",
-    "        ax.plot(x, y, 'o')\n",
-    "\n",
-    "\n",
-    "    plt.show()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 7,
-   "id": "6dc888d7-a2c1-4319-91d3-6aca0a966203",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def plot_scaled_quartets(float_dataset, discrete_dataset):\n",
-    "\n",
-    "    fig, axs = plt.subplots(2, 4, sharex=True, sharey=True, figsize=(12, 6),\n",
-    "                            gridspec_kw={'wspace': 0.08, 'hspace': 0.08})\n",
-    "    \n",
-    "    colors = [\"firebrick\", \"teal\"]\n",
-    "    \n",
-    "    for i, j in enumerate(range(0, 4, 2)):\n",
-    "        \n",
-    "        if i == 0:\n",
-    "            dataset = float_dataset\n",
-    "        else: \n",
-    "            dataset = discrete_dataset\n",
-    "            \n",
-    "        for ax, (label, (x, y)) in zip(axs[:, j:j+2].flat, dataset.items()):\n",
-    "            ax.text(0.1, 0.9, label, fontsize=20, transform=ax.transAxes, va='top')\n",
-    "            ax.tick_params(direction='in', top=True, right=True)\n",
-    "            ax.plot(x, y, 'o', c=colors[i])\n",
-    "            \n",
-    "    axs[0, 0].set_title(\"Float\", loc=\"left\") \n",
-    "    axs[0, 2].set_title(\"Discrete\", loc=\"left\") \n",
-    "\n",
-    "    plt.show()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "b1b057d4-852e-4ba6-95ae-bcf193fdbec2",
-   "metadata": {},
-   "source": [
-    "#### Original Anscombe's Quartet datasets"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 8,
-   "id": "8f95cb45-ccad-4576-87de-02b860bdff6b",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "x =  [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5]\n",
-    "y1 = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]\n",
-    "y2 = [9.14, 8.14, 8.74, 8.77, 9.26, 8.10, 6.13, 3.10, 9.13, 7.26, 4.74]\n",
-    "y3 = [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]\n",
-    "x4 = [8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8]\n",
-    "y4 = [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.50, 5.56, 7.91, 6.89]\n",
-    "\n",
-    "datasets = {\n",
-    "    'I': (x, y1),\n",
-    "    'II': (x, y2),\n",
-    "    'III': (x, y3),\n",
-    "    'IV': (x4, y4)\n",
-    "}"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 9,
-   "id": "67dc8645-5d87-4ab5-8965-43d6ff63a892",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 432x432 with 4 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "plot_original_quartet(datasets)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "e1172759-dcfc-4081-9163-f50f37665c4e",
-   "metadata": {},
-   "source": [
-    "#### Scaling to a given mean and standard deviation\n",
-    "No difference between float and discrete option"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 10,
-   "id": "bf8b7c19-4739-4071-a465-9b3b88aa6039",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 8 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "mean_std_datasets_float = transform_datasets(datasets, scale_to_mean_std, discrete=False)\n",
-    "mean_std_datasets_discrete = transform_datasets(datasets, scale_to_mean_std, discrete=True)\n",
-    "\n",
-    "plot_scaled_quartets(mean_std_datasets_float, mean_std_datasets_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "3e80a982-14d8-4c78-8ea2-4554363383f1",
-   "metadata": {},
-   "source": [
-    "#### Scaling to given range\n",
-    "No difference between float and discrete option"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 11,
-   "id": "47edeedd-c098-46dc-95e3-724242d43973",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 8 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "range_datasets_float = transform_datasets(datasets, scale_to_range, discrete=False, target_min=-20, target_max=200)\n",
-    "range_datasets_discrete = transform_datasets(datasets, scale_to_range, discrete=True, target_min=-20, target_max=200)\n",
-    "\n",
-    "plot_scaled_quartets(range_datasets_float, range_datasets_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "ecc30b65-54ed-409a-932e-4d605ac95732",
-   "metadata": {},
-   "source": [
-    "#### Scaling to given target sum\n",
-    "Discrete will change the shape of the distribution somewhat - particularly if the values are small"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 12,
-   "id": "698268bc-e5fc-4a4d-86e2-220ff4f276a5",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 8 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "target_sum_datasets_float = transform_datasets(datasets, scale_to_sum, discrete=False)\n",
-    "target_sum_datasets_discrete = transform_datasets(datasets, scale_to_sum, discrete=True)\n",
-    "\n",
-    "plot_scaled_quartets(target_sum_datasets_float, target_sum_datasets_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "d7391d20-fb6e-4057-8e94-238a4a46be18",
-   "metadata": {},
-   "source": [
-    "#### Multivariate distribution\n",
-    "These examples are closer to the real use case where the final distribution is made up from a number of normal distributions with a varying mean.\n",
-    "\n",
-    "Code adapted from this SO [answer](https://stackoverflow.com/questions/47759577/creating-a-mixture-of-probability-distributions-for-sampling)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 13,
-   "id": "a6cb5d56-49d4-44fe-9bf7-eb4ed479f979",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def plot_distribution(float_dataset, discrete_dataset,):\n",
-    "\n",
-    "    fig, axs = plt.subplots(1, 2, sharex=True, sharey=True, figsize=(12, 6),\n",
-    "                            gridspec_kw={'wspace': 0.08, 'hspace': 0.08})\n",
-    "    \n",
-    "    colors = [\"firebrick\", \"teal\"]\n",
-    "    \n",
-    "    for i in range(2):\n",
-    "        \n",
-    "        if i == 0:\n",
-    "            dataset = float_dataset\n",
-    "        else: \n",
-    "            dataset = discrete_dataset\n",
-    "        \n",
-    "        axs[i].hist(dataset, bins=100, density=False, color=colors[i], alpha=0.5)\n",
-    "        \n",
-    "            \n",
-    "    axs[0].set_title(\"Float\", loc=\"left\") \n",
-    "    axs[1].set_title(\"Discrete\", loc=\"left\") \n",
-    "\n",
-    "    plt.show()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 14,
-   "id": "7a91216a-b05d-4d74-937d-4ba9a8842455",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "distributions = [\n",
-    "    {\"type\": np.random.normal, \"kwargs\": {\"loc\": 20, \"scale\": 2}},\n",
-    "    {\"type\": np.random.normal, \"kwargs\": {\"loc\": 10, \"scale\": 3}},\n",
-    "    {\"type\": np.random.normal, \"kwargs\": {\"loc\": 5, \"scale\": 1}},\n",
-    "]\n",
-    "coefficients = np.array([0.5, 0.2, 0.3])\n",
-    "coefficients /= coefficients.sum()\n",
-    "sample_size = 100000\n",
-    "\n",
-    "num_distr = len(distributions)\n",
-    "data = np.zeros((sample_size, num_distr))\n",
-    "for idx, distr in enumerate(distributions):\n",
-    "    data[:, idx] = distr[\"type\"](size=(sample_size,), **distr[\"kwargs\"])\n",
-    "random_idx = np.random.choice(np.arange(num_distr), size=(sample_size,), p=coefficients)\n",
-    "sample = data[np.arange(sample_size), random_idx]"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 15,
-   "id": "4d7e667b-c281-44a1-a30a-1d87679b02fa",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 432x288 with 1 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "plt.hist(sample, bins=100)\n",
-    "plt.show()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "1e759c95-33bc-43d6-9188-4929184936e9",
-   "metadata": {},
-   "source": [
-    "#### Scale to positive mean and standard distribution"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 16,
-   "id": "257c5e10-2172-452f-b28c-7d681e227e6a",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "mean_std_float = scale_to_mean_std(sample, discrete=False)\n",
-    "mean_std_discrete = scale_to_mean_std(sample, discrete=True)\n",
-    "\n",
-    "plot_distribution(mean_std_float, mean_std_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "621bc30a-880c-4099-9c31-97fafff4ca52",
-   "metadata": {},
-   "source": [
-    "#### Scale to negative mean and standard distribution"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 17,
-   "id": "2bb24e13-9c77-48d2-a749-3b2993b8f73a",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "mean_std_float = scale_to_mean_std(sample, target_mean=-20, discrete=False)\n",
-    "mean_std_discrete = scale_to_mean_std(sample, target_mean=-20, discrete=True)\n",
-    "\n",
-    "plot_distribution(mean_std_float, mean_std_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "e1b6f2bb-0bb3-4839-aee5-07d966924deb",
-   "metadata": {},
-   "source": [
-    "#### Scale to a given range\n",
-    "Both ranges are given"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 18,
-   "id": "bcbd655e-6930-40cd-bcc3-2f0b26d42abb",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "range_float = scale_to_range(sample, discrete=False, target_min=-100, target_max=200)\n",
-    "range_discrete = scale_to_range(sample, discrete=True, target_min=-100, target_max=200)\n",
-    "\n",
-    "plot_distribution(range_float, range_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "8ea5f81c-d72c-4924-b031-a0d026fac47f",
-   "metadata": {},
-   "source": [
-    "##### Special case if only one of the ranges is given (negative min)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 19,
-   "id": "cc6c5ba5-1879-46d6-8366-48faeb237f90",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "range_float = scale_to_range(sample, discrete=False, target_min=-100)\n",
-    "range_discrete = scale_to_range(sample, discrete=True, target_min=-100)\n",
-    "\n",
-    "plot_distribution(range_float, range_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "15f0c099-9ca9-49ee-8535-9dcb2935e027",
-   "metadata": {},
-   "source": [
-    "##### Special case if only one of the ranges is given (small max)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 20,
-   "id": "d16d1590-0c9b-4b82-8196-770d0e30a238",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "range_float = scale_to_range(sample, discrete=False, target_max=10)\n",
-    "range_discrete = scale_to_range(sample, discrete=True, target_max=10)\n",
-    "\n",
-    "plot_distribution(range_float, range_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "de68a66e-a980-41f4-be8a-76c1c22a1665",
-   "metadata": {},
-   "source": [
-    "##### Special case for 0 and 1"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 21,
-   "id": "fa96ae3d-d85f-4a9b-87e0-adfaf6f87bd7",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "range_float = scale_to_range(sample, discrete=False, target_min=0, target_max=1)\n",
-    "range_discrete = scale_to_range(sample, discrete=True, target_min=0, target_max=1)\n",
-    "\n",
-    "plot_distribution(range_float, range_discrete)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "3726793b-93d0-4d75-8eb2-4139b319ade8",
-   "metadata": {},
-   "source": [
-    "#### Scale to a target sum\n",
-    "Given a large enough sum (corresponding to the 10_000 samples!), it's OK. But at smaller values zeroes will dominate."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 22,
-   "id": "2a363f73-cb18-477f-a390-0e0956f619c9",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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\n",
-      "text/plain": [
-       "<Figure size 864x432 with 2 Axes>"
-      ]
-     },
-     "metadata": {
-      "needs_background": "light"
-     },
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "sum_float = scale_to_sum(sample, discrete=False, target_sum=1_000_000)\n",
-    "sum_discrete = scale_to_sum(sample, discrete=True, target_sum=1_000_000)\n",
-    "\n",
-    "plot_distribution(sum_float, sum_discrete)"
-   ]
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3",
-   "language": "python",
-   "name": "python3"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.9.5"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "0c41daba-6432-4378-98e6-54cde5b9f03b",
+   "metadata": {},
+   "source": [
+    "### Background notes"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d5ca10d1-632d-4e50-ba45-4e4ffba47575",
+   "metadata": {},
+   "source": [
+    "In `exhibit` you can generate numerical values either from a uniform random distribution or from a normal distribution. These values can then be coerced to either floats or integers (as a hack to get discrete values like 0 and 1). It's unlikely you'd want to use generated values as is, so we apply linear scaling before returning the values to the user.\n",
+    "\n",
+    "To draw from a uniform distribution all we need is a starting value and the optional dispersion (noise) percentage to shift the final value around. This uniform value is still affected by feature weights, but with dispersion set to 0 you can get consistent values for the same row values. \n",
+    "\n",
+    "For normal distribution, you'd need the mean and the standard deviation. The two statistics, however, will change by the time you finish generating the dataset because for each row, the weights will affect the mean. Thus, you can use them as initial values, but will have to rescale at the end if you want to keep the original values. If the mean / std are commented out in the spec (user intending to scale to range, for example), then we'll use default mean of 1 (having it as zero will negate mean shifting by weights) and standard deviation of 1.\n",
+    "\n",
+    "Additionally, if the generated distribution has negative values, we will shift the whole set to be in the positive territory if the scaling is set to `target_sum`; other scaling options can be used with negative ranges / statistics.\n",
+    "\n",
+    "The scaling options are:\n",
+    "\n",
+    " - target range\n",
+    " - target statistic (`sum`, `mean`, `std`)\n",
+    " \n",
+    "The goal of the scaling options is to match user expectations and to preseve the shape of the data. Unfortunately, you can't have all matching statistics so by choosing to align to a certain metric, you leave others free-floating. This is so that we don't lose information from the weights and preseve the shape of the data.\n",
+    "\n",
+    "| scaling | min / max | sum | mean | standard deviation | weights |\n",
+    "| :- | :- | :- | :- | :- | :- |\n",
+    "| target range | preserved | free-floating | free-floating | free-floating | __*__ |\n",
+    "| target statistic (sum) | free-floating | preserved | free-floating | free-floating | preserved |\n",
+    "| target statistic (mean + std) | free-floating | free-floating | preserved | preserved | preserved |\n",
+    "\n",
+    "__\\*__ _if the ratio between target_min and target_max doesn't match the generated min and max, either the weights will change\n",
+    "or one of the target ranges will need to be adjusted. For example, assume you have 4 values influencing the weights - `0.05, 0.15, 0.3, 0.4`. You want to scale your generated data to between 10 and 150. Without scaling, the generated data can have values like `25, 75, 150, 200`. Now, the ratio `0.3 / 0.15` is the same as `150 / 75`. However, if we scale to between 10 and 150, we get `10, 50, 110, 150`. And here, we're dealing with intervals - `0.1 = 40` so values with weights 0.15 and 0.3 are separated by 60. Although this is not particularly intuitive, it's still preferable over adjusting the `target_max` like so `target_max = (target_min * generated.max()) / generated.min()` because often you have to have fixed min-max ranges and also adjusting the maximum in this way can lead to unexpected results, like pushing the maximum into the negative territory._\n",
+    "\n",
+    "When generating the specification from the original data, we populate all potential fields: `min`, `max`, `sum`, etc. Rather than creating separate functions for different statistics, we'll try to make sense of what we're given and issue a warning if an impossible or conflicting situation is given.\n",
+    "\n",
+    "For example:\n",
+    "\n",
+    "| min | max | sum | mean | standard deviation | outcome |\n",
+    "| :- | :- | :- | :- | :- |:- |\n",
+    "| given | missing | missing | missing | missing | derive the missing end of the range\n",
+    "| given | given | missing | missing | missing | valid\n",
+    "| given | given | given | given | given | warning - use sum as default\n",
+    "| missing | missing | given | given | given | warning - use sum as default\n",
+    "| missing | missing | missing | given | given | scale to both\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5c1ec07e-d3df-4bd3-a3d8-b7cee08294c0",
+   "metadata": {},
+   "source": [
+    "#### Scaling functions\n",
+    "Scaling floats works as expected. However, as soon as you ask for discrete values, we're at the mercy of rounding which is OK in the large registers, but will produce highly imbalanced datasets when values are low or when standard deviation is small. We try to compensate for it in both scaling to range and scaling to target sum, but if we only have mean / standard deviation to work with, the results will likely be quite imprecise."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "c5386e8b-ca06-46f4-b55f-d42501f5917f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "import numpy as np\n",
+    "import matplotlib.pyplot as plt"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "cf4670b6-12dd-4a23-b1bd-61557ca96084",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def scale_to_mean_std(array, target_mean=100, target_std=20, discrete=False):\n",
+    "    \n",
+    "    arr = np.array(array)\n",
+    "    \n",
+    "    result = target_mean + (arr - arr.mean()) * target_std / arr.std()\n",
+    "    \n",
+    "    if discrete:\n",
+    "        result = result.round()\n",
+    "    \n",
+    "    return result"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "fa835db8-eeed-4eec-9a07-a143098151f4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def scale_to_range(array, target_min=None, target_max=None, discrete=False):\n",
+    "    \n",
+    "    X = np.array(array)\n",
+    "    \n",
+    "    # adjust for potential negative signs!\n",
+    "    if not target_min:\n",
+    "\n",
+    "        target_min = target_max - (abs(target_max) - abs(target_max * X.min() / X.max()))\n",
+    "    \n",
+    "    if not target_max:\n",
+    "\n",
+    "        target_max = target_min + abs(target_min * X.max() / X.min()) - abs(target_min)\n",
+    "\n",
+    "    if discrete:\n",
+    "\n",
+    "        target_range = int(np.ceil(target_max) - np.floor(target_min))\n",
+    "        bins = np.linspace(X.min(), X.max(), target_range + 2)\n",
+    "        labels = np.arange(np.floor(target_min), np.ceil(target_max) + 1)\n",
+    "\n",
+    "        result = pd.cut(X, bins=bins, right=True, include_lowest=True, labels=labels).to_numpy()\n",
+    "        \n",
+    "        return result\n",
+    "    \n",
+    "    result = (X - X.min()) / (X.max() - X.min()) * (target_max - target_min) + target_min\n",
+    "\n",
+    "    return result"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "4d0823e7-a911-402e-b458-f2fecf879df0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def scale_to_sum(array, target_sum=200, discrete=False):\n",
+    "    \n",
+    "    series = pd.Series(array)\n",
+    "    \n",
+    "    if any(series < 0):\n",
+    "        series = series + abs(series.min())\n",
+    "    \n",
+    "    scaling_factor = target_sum / series.dropna().sum()\n",
+    "    scaled_series = series * scaling_factor\n",
+    "    \n",
+    "    if discrete:\n",
+    "        \n",
+    "        row_diff = (target_sum - series.dropna().sum()) / len(series.dropna())\n",
+    "        values = pd.Series(\n",
+    "            np.where(\n",
+    "                series + row_diff >= 0,\n",
+    "                series + row_diff,\n",
+    "                np.where(pd.isnull(series), np.nan, 0)\n",
+    "                )\n",
+    "        )\n",
+    "    \n",
+    "        #how many rows will need to be rounded up to get to target\n",
+    "        boundary = int(target_sum - np.floor(values).sum())\n",
+    "\n",
+    "        #because values are limited at the lower end at zero, sometimes it's not possible\n",
+    "        #to adjust them to a lower target_sum; we floor them and return\n",
+    "        if boundary < 0:\n",
+    "            return pd.Series(np.floor(values)).to_numpy()\n",
+    "\n",
+    "        #if series has NAs, then the calcualtion will be off\n",
+    "        clean_values = values.dropna() #keep original index\n",
+    "\n",
+    "        #np.ceil and floor return Series so index is preserved\n",
+    "        values.update(np.maximum(np.ceil(clean_values.iloc[0:boundary]), 1))\n",
+    "        values.update(np.floor(clean_values.iloc[boundary:]))\n",
+    "\n",
+    "        #return a series of ints or cast to float if there are any NAs\n",
+    "        #see https://github.com/pandas-dev/pandas/issues/29618\n",
+    "        #before migrating to the new Pandas null-aware int dtype\n",
+    "        scaled_series = values if values.isna().any() else values.astype(int)\n",
+    "    \n",
+    "    return scaled_series"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "176afd13-c1a2-42ca-818b-fe36fb80b31a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def transform_datasets(datasets, func, **kwargs):\n",
+    "\n",
+    "    new_datasets = {}\n",
+    "\n",
+    "    for i, dataset in datasets.items():\n",
+    "        new_datasets[i] = (\n",
+    "            func(dataset[0], **kwargs),\n",
+    "            func(dataset[1], **kwargs)\n",
+    "        )\n",
+    "    \n",
+    "    return new_datasets"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "1a0ca1a9-2f58-4d62-8d28-a5ee7715bce0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def plot_original_quartet(datasets):\n",
+    "    '''\n",
+    "    From Matplotlib gallery: https://matplotlib.org/stable/gallery/specialty_plots/anscombe.html\n",
+    "    '''\n",
+    "\n",
+    "    fig, axs = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(6, 6),\n",
+    "                            gridspec_kw={'wspace': 0.08, 'hspace': 0.08})\n",
+    "            \n",
+    "    for ax, (label, (x, y)) in zip(axs.flat, datasets.items()):\n",
+    "        ax.text(0.1, 0.9, label, fontsize=20, transform=ax.transAxes, va='top')\n",
+    "        ax.tick_params(direction='in', top=True, right=True)\n",
+    "        ax.plot(x, y, 'o')\n",
+    "\n",
+    "\n",
+    "    plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "6dc888d7-a2c1-4319-91d3-6aca0a966203",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def plot_scaled_quartets(float_dataset, discrete_dataset):\n",
+    "\n",
+    "    fig, axs = plt.subplots(2, 4, sharex=True, sharey=True, figsize=(12, 6),\n",
+    "                            gridspec_kw={'wspace': 0.08, 'hspace': 0.08})\n",
+    "    \n",
+    "    colors = [\"firebrick\", \"teal\"]\n",
+    "    \n",
+    "    for i, j in enumerate(range(0, 4, 2)):\n",
+    "        \n",
+    "        if i == 0:\n",
+    "            dataset = float_dataset\n",
+    "        else: \n",
+    "            dataset = discrete_dataset\n",
+    "            \n",
+    "        for ax, (label, (x, y)) in zip(axs[:, j:j+2].flat, dataset.items()):\n",
+    "            ax.text(0.1, 0.9, label, fontsize=20, transform=ax.transAxes, va='top')\n",
+    "            ax.tick_params(direction='in', top=True, right=True)\n",
+    "            ax.plot(x, y, 'o', c=colors[i])\n",
+    "            \n",
+    "    axs[0, 0].set_title(\"Float\", loc=\"left\") \n",
+    "    axs[0, 2].set_title(\"Discrete\", loc=\"left\") \n",
+    "\n",
+    "    plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b1b057d4-852e-4ba6-95ae-bcf193fdbec2",
+   "metadata": {},
+   "source": [
+    "#### Original Anscombe's Quartet datasets"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "8f95cb45-ccad-4576-87de-02b860bdff6b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x =  [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5]\n",
+    "y1 = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]\n",
+    "y2 = [9.14, 8.14, 8.74, 8.77, 9.26, 8.10, 6.13, 3.10, 9.13, 7.26, 4.74]\n",
+    "y3 = [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]\n",
+    "x4 = [8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8]\n",
+    "y4 = [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.50, 5.56, 7.91, 6.89]\n",
+    "\n",
+    "datasets = {\n",
+    "    'I': (x, y1),\n",
+    "    'II': (x, y2),\n",
+    "    'III': (x, y3),\n",
+    "    'IV': (x4, y4)\n",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "67dc8645-5d87-4ab5-8965-43d6ff63a892",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x432 with 4 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "plot_original_quartet(datasets)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e1172759-dcfc-4081-9163-f50f37665c4e",
+   "metadata": {},
+   "source": [
+    "#### Scaling to a given mean and standard deviation\n",
+    "No difference between float and discrete option"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "bf8b7c19-4739-4071-a465-9b3b88aa6039",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 8 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "mean_std_datasets_float = transform_datasets(datasets, scale_to_mean_std, discrete=False)\n",
+    "mean_std_datasets_discrete = transform_datasets(datasets, scale_to_mean_std, discrete=True)\n",
+    "\n",
+    "plot_scaled_quartets(mean_std_datasets_float, mean_std_datasets_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3e80a982-14d8-4c78-8ea2-4554363383f1",
+   "metadata": {},
+   "source": [
+    "#### Scaling to given range\n",
+    "No difference between float and discrete option"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "47edeedd-c098-46dc-95e3-724242d43973",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 8 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "range_datasets_float = transform_datasets(datasets, scale_to_range, discrete=False, target_min=-20, target_max=200)\n",
+    "range_datasets_discrete = transform_datasets(datasets, scale_to_range, discrete=True, target_min=-20, target_max=200)\n",
+    "\n",
+    "plot_scaled_quartets(range_datasets_float, range_datasets_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ecc30b65-54ed-409a-932e-4d605ac95732",
+   "metadata": {},
+   "source": [
+    "#### Scaling to given target sum\n",
+    "Discrete will change the shape of the distribution somewhat - particularly if the values are small"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "id": "698268bc-e5fc-4a4d-86e2-220ff4f276a5",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 8 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "target_sum_datasets_float = transform_datasets(datasets, scale_to_sum, discrete=False)\n",
+    "target_sum_datasets_discrete = transform_datasets(datasets, scale_to_sum, discrete=True)\n",
+    "\n",
+    "plot_scaled_quartets(target_sum_datasets_float, target_sum_datasets_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d7391d20-fb6e-4057-8e94-238a4a46be18",
+   "metadata": {},
+   "source": [
+    "#### Multivariate distribution\n",
+    "These examples are closer to the real use case where the final distribution is made up from a number of normal distributions with a varying mean.\n",
+    "\n",
+    "Code adapted from this SO [answer](https://stackoverflow.com/questions/47759577/creating-a-mixture-of-probability-distributions-for-sampling)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "id": "a6cb5d56-49d4-44fe-9bf7-eb4ed479f979",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def plot_distribution(float_dataset, discrete_dataset,):\n",
+    "\n",
+    "    fig, axs = plt.subplots(1, 2, sharex=True, sharey=True, figsize=(12, 6),\n",
+    "                            gridspec_kw={'wspace': 0.08, 'hspace': 0.08})\n",
+    "    \n",
+    "    colors = [\"firebrick\", \"teal\"]\n",
+    "    \n",
+    "    for i in range(2):\n",
+    "        \n",
+    "        if i == 0:\n",
+    "            dataset = float_dataset\n",
+    "        else: \n",
+    "            dataset = discrete_dataset\n",
+    "        \n",
+    "        axs[i].hist(dataset, bins=100, density=False, color=colors[i], alpha=0.5)\n",
+    "        \n",
+    "            \n",
+    "    axs[0].set_title(\"Float\", loc=\"left\") \n",
+    "    axs[1].set_title(\"Discrete\", loc=\"left\") \n",
+    "\n",
+    "    plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "id": "7a91216a-b05d-4d74-937d-4ba9a8842455",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "distributions = [\n",
+    "    {\"type\": np.random.normal, \"kwargs\": {\"loc\": 20, \"scale\": 2}},\n",
+    "    {\"type\": np.random.normal, \"kwargs\": {\"loc\": 10, \"scale\": 3}},\n",
+    "    {\"type\": np.random.normal, \"kwargs\": {\"loc\": 5, \"scale\": 1}},\n",
+    "]\n",
+    "coefficients = np.array([0.5, 0.2, 0.3])\n",
+    "coefficients /= coefficients.sum()\n",
+    "sample_size = 100000\n",
+    "\n",
+    "num_distr = len(distributions)\n",
+    "data = np.zeros((sample_size, num_distr))\n",
+    "for idx, distr in enumerate(distributions):\n",
+    "    data[:, idx] = distr[\"type\"](size=(sample_size,), **distr[\"kwargs\"])\n",
+    "random_idx = np.random.choice(np.arange(num_distr), size=(sample_size,), p=coefficients)\n",
+    "sample = data[np.arange(sample_size), random_idx]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "id": "4d7e667b-c281-44a1-a30a-1d87679b02fa",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "plt.hist(sample, bins=100)\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1e759c95-33bc-43d6-9188-4929184936e9",
+   "metadata": {},
+   "source": [
+    "#### Scale to positive mean and standard distribution"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 16,
+   "id": "257c5e10-2172-452f-b28c-7d681e227e6a",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "mean_std_float = scale_to_mean_std(sample, discrete=False)\n",
+    "mean_std_discrete = scale_to_mean_std(sample, discrete=True)\n",
+    "\n",
+    "plot_distribution(mean_std_float, mean_std_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "621bc30a-880c-4099-9c31-97fafff4ca52",
+   "metadata": {},
+   "source": [
+    "#### Scale to negative mean and standard distribution"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "id": "2bb24e13-9c77-48d2-a749-3b2993b8f73a",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "iVBORw0KGgoAAAANSUhEUgAAAswAAAF1CAYAAAD8/Lw6AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8QVMy6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAa6klEQVR4nO3df7BuV1kf8O9DooEqCGluaEgCF4aQNtD6gxjjUNurOE1s0eQPqbFTSFtqakrt1WolwLTeThuN2tFeqmBTZQgjI82MCCkjU0PqrVqBNLFgDOGWKBJi0iRILcFqJOHpH+8Oee+95657zs05533PeT+fmXfOftfe+z1rr3vumu9ZZ+21q7sDAACs7SmLrgAAACwzgRkAAAYEZgAAGBCYAQBgQGAGAIABgRkAAAZ2bGCuqr1V1VV16qLrArAqqupnqupfLLoeANtpRwTmqvr9qvqTqvrc468kz9nEz++qeuFmfR7ATjXX3z5cVX9UVb9ZVd9dVU9Jku7+7u7+19tYn31Vde92fT+AteyIwDz51u7+8sdfSe5bdIUAdqlv7e6nJ3lekuuSvC7Jz23FN/JXQmAn2EmBeaiqnlNVN1XVZ6rq7qr6rrl9F1XVB6bRkvur6qeq6kunfb82HfaRafT6OxZyAQBLprv/b3fflOQ7klxZVS+pqrdV1b9Jkqo6o6reO/Wtn6mqX398JLqqzq2qd1XVQ1X1h1X1U1P536uq/15VP1lVn0lyoKpOq6p/W1X3VNUD07SPp1XVlyV5X5LnzP2F8TlV9ZSquqaqfnf67Bur6vQFNROwAnZNYE7yC0nuzWyqxrcn+eGqevm077Ek35fkjCRfn+TlSf5xknT3X5uO+cpp9Po/bWutAZZcd9+aWf/6DUft+v6pfE+SZyd5Q5KuqlOSvDfJJ5PsTXJ2knfOnfd1SX4vyZlJrk3yo0lelOSrkrxwOv5fdvcfJ/mWJPfN/YXxviT/NMnlSf56Zn3+/0ny05t5zQDzdlJgfvc0ivFHVfXu+R1VdW6Sv5rkdd39p9394SQ/m+RVSdLdt3f3B7v70e7+/ST/IbOOFoD1uS/J0aO4n09yVpLndffnu/vXu7uTXJRZkP3n3f3HU7/8G/Of1d3/vrsfTfKnSb4ryfd192e6++EkP5zkikFd/lGSN3b3vd39SJIDSb7d9A5gq+ykzuXy7n7/42+qau/cvuckebyjfdwnk1w4HfuiJD8xvf9zmV337VtdYYBd5Owknzmq7MczC6u/UlVJcn13X5fk3CSfnALxWj41t70ns3759ukzkqSSnDKoy/OS/FJVfWGu7LHMRrn/4IRXArBBO2mEeeS+JKdX1dPnyp6bJzrOtyT5WJLzuvsZmf3ZsALACVXV12YWmOdHidPdD3f393f3C5J8a5J/Nk2F+1SS5w5GfHtu+9NJ/iTJi7v7mdPrK6abu48+9nGfSvItc8c/s7uf2t3CMrAldkVg7u5PJfnNJD9SVU+tqr+S5DVJ3jEd8vQkn03yuar6i0muPuojHkjygu2qL8BOUFXPqKpXZDb/+Oe7+46j9r+iql5Ys6Hhz2Y2yvtYkluT3J/kuqr6sqlfftla36O7v5DkPyb5yao6c/rcs6vqkumQB5L8+ar6irnTfibJtVX1vOn4PVV12WZdN8DRdkVgnnxnZjeX3Jfkl5L8UHffPO37gSR/J8nDmXXMR9/YdyDJDdP86L+9LbUFWF7/uaoezmwk942ZTWn7+2scd16S9yf5XJIPJHlzdx/q7scyG3F+YZJ7MrsxcLQC0euS3J3kg1X12ekzz0+S7v5YZjd1/97URz8nycEkN2U2FeThJB/M7EZCgC1Rs/szAACAteymEWYAANh0AjMAAAwIzAAAMCAwAwDAgMAMAAADS/+kvzPOOKP37t276GoALMztt9/+6e7es5XfQ18LrLpRX7v0gXnv3r257bbbFl0NgIWpqk9u9ffQ1wKrbtTXmpIBAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADpy66AgCwUxw4dOiJ7X37FlYPYHsZYQYAgAGBGQAABgRmAAAYEJgBAGBAYAYAgAGBGQAABiwrBwAD80vJAavJCDMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAysOzBX1SlV9T+r6r3T+9Or6uaq+vj09Vlzx76+qu6uqsNVdclc+Uur6o5p35uqqjb3cgAAYHNtZIR5f5K75t5fk+SW7j4vyS3T+1TVBUmuSPLiJJcmeXNVnTKd85YkVyU5b3pd+qRqDwBL4MChQ198AbvPugJzVZ2T5G8l+dm54suS3DBt35Dk8rnyd3b3I939iSR3J7moqs5K8ozu/kB3d5K3z50DAABLab0jzP8uyQ8m+cJc2bO7+/4kmb6eOZWfneRTc8fdO5WdPW0fXX6Mqrqqqm6rqtseeuihdVYRgI3Q1wKszwkDc1W9IsmD3X37Oj9zrXnJPSg/trD7+u6+sLsv3LNnzzq/LQAboa8FWJ9T13HMy5J8W1X9zSRPTfKMqvr5JA9U1Vndff803eLB6fh7k5w7d/45Se6bys9ZoxwAAJbWCUeYu/v13X1Od+/N7Ga+/9rdfzfJTUmunA67Msl7pu2bklxRVadV1fMzu7nv1mnaxsNVdfG0Osar584BAICltJ4R5uO5LsmNVfWaJPckeWWSdPedVXVjko8meTTJa7v7semcq5O8LcnTkrxvegEAwNLaUGDu7kNJDk3bf5jk5cc57tok165RfluSl2y0kgAAsCie9AcAAAMCMwAADAjMAAAwIDADAMCAwAwAAAMCMwAADAjMAAAw8GQeXMISOnzw4Be3z9+/f4E1AQDYHQTmXWA+JAMAsLlMyQAAgAGBGQAABgRmAAAYEJgBAGBAYAYAgAGBGQAABgRmAAAYEJgBAGBAYAYAgAGBGQAABgRmAAAYEJgBAGBAYAYAgIFTF10BAFg2Bw4dWnQVgCVihBkAAAYEZgAAGBCYAQBgQGAGAIABgRkAAAaskrFDHT54cNFVAABYCUaYAQBgwAgzAGyi+TWcD+zbt7B6sLz8jOw8AjMAwBbzMJydzZQMAAAYEJgBAGBAYN7FDh88aDUNAIAnSWAGAIABgRkAAAYEZgAAGBCYAQBgQGAGAIABgRkAAAY86Q8AYAt4ut/uITADACyB+YB9YN++hdWDY5mSAQAAAwIzAAAMmJIBALBkTM9YLkaYAQBgQGAGAIABgRkAAAYEZgAAGBCYAQBgQGAGAIABy8oBAGwSj8PenYwwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAycMDBX1VOr6taq+khV3VlV/2oqP72qbq6qj09fnzV3zuur6u6qOlxVl8yVv7Sq7pj2vamqamsuC9iowwcPfvEFADxhPSPMjyT5pu7+yiRfleTSqro4yTVJbunu85LcMr1PVV2Q5IokL05yaZI3V9Up02e9JclVSc6bXpdu3qUAAMDmO2Fg7pnPTW+/ZHp1ksuS3DCV35Dk8mn7siTv7O5HuvsTSe5OclFVnZXkGd39ge7uJG+fOwcAAJbSuuYwV9UpVfXhJA8mubm7P5Tk2d19f5JMX8+cDj87yafmTr93Kjt72j66HAAAlta6AnN3P9bdX5XknMxGi18yOHyteck9KD/2A6quqqrbquq2hx56aD1VBGCD9LUA67OhVTK6+4+SHMps7vED0zSLTF8fnA67N8m5c6edk+S+qfycNcrX+j7Xd/eF3X3hnj17NlJFANZJXwuwPutZJWNPVT1z2n5akm9O8rEkNyW5cjrsyiTvmbZvSnJFVZ1WVc/P7Oa+W6dpGw9X1cXT6hivnjuHLWT1AwCAk3fqOo45K8kN00oXT0lyY3e/t6o+kOTGqnpNknuSvDJJuvvOqroxyUeTPJrktd392PRZVyd5W5KnJXnf9AIA4DgOHDr0xPa+fQurxyo7YWDu7t9O8tVrlP9hkpcf55xrk1y7RvltSUbznwEAYKl40h9wDFN4AOAJ65mSAexSQjHAkzc/ZYLdyQgzAAAMCMwAADAgMAMAwIA5zMBxzc9xPn///gXWBAAWxwgzAAAMCMwAADBgSgawLqZnALCqjDADAMCAEeYdxEMmAAC2n8AMK8YvXgCwMaZkAADAgMAMAAADAjMAAAyYwwwrwLxlADh5AjMAwEk4cOjQoqvANjElAwAABowwA8AWmR+BPLBv38LqATw5RpgBAGBAYAYAgAFTMgAgbuACjk9gBjZsfpm68/fvX2BNAGDrmZIBAAADAjMAAAyYkgEAsENYqnAxjDADAMCAwAwAAAMCMwAADJjDDLvU/NJvAMDJM8IMAAADAjMAAAyYkrFiPKENAGBjjDADAMCAwAwAAAMCMwAADAjMAAAwIDADAMCAwAwAAAMCMwAADFiHGXYRj8MGgM1nhBkAAAYEZgAAGBCYAQBgwBxm4EmZnzd9/v79C6wJAGwNI8wAADBghBkAYAc6cOjQE9v79i2sHqvACDMAAAwIzAAAMCAwAwDAgDnMAAAD5gpjhBkAAAYEZgAAGBCYAQBgQGAGAIABgRkAAAaskrHkDh88uOgqwLrN/7yev3//AmsCAJvHCDMAAAwIzAAAMCAwAwDAgMAMAAADJwzMVXVuVf1qVd1VVXdW1f6p/PSqurmqPj59fdbcOa+vqrur6nBVXTJX/tKqumPa96aqqq25LAAA2BzrGWF+NMn3d/dfSnJxktdW1QVJrklyS3efl+SW6X2mfVckeXGSS5O8uapOmT7rLUmuSnLe9Lp0E68FAAA23QkDc3ff392/NW0/nOSuJGcnuSzJDdNhNyS5fNq+LMk7u/uR7v5EkruTXFRVZyV5Rnd/oLs7ydvnzgEAgKW0oXWYq2pvkq9O8qEkz+7u+5NZqK6qM6fDzk7ywbnT7p3KPj9tH10OPAnW6gaArbXum/6q6suT/GKS7+3uz44OXaOsB+Vrfa+rquq2qrrtoYceWm8VAdgAfS3A+qwrMFfVl2QWlt/R3e+aih+Ypllk+vrgVH5vknPnTj8nyX1T+TlrlB+ju6/v7gu7+8I9e/as91oA2AB9LcD6rGeVjEryc0nu6u6fmNt1U5Irp+0rk7xnrvyKqjqtqp6f2c19t07TNx6uqounz3z13DkAsKsdOHToiy9gZ1nPHOaXJXlVkjuq6sNT2RuSXJfkxqp6TZJ7krwySbr7zqq6MclHM1th47Xd/dh03tVJ3pbkaUneN70AAGBpnTAwd/dvZO35x0ny8uOcc22Sa9covy3JSzZSQQAAWCRP+gMAgAGBGQAABgRmAAAYEJgBAGBAYAYAgIENPRobAGAVWC+beQLzCjt88OAXt8/fv3+BNQEAWF6mZAAAwIDADAAAAwIzAAAMCMwAADDgpj8AVprVEIATMcIMALDDHTh0yC9/W8gIM7AlLFsIwG5hhBkAAAYEZgAAGDAlA3ag+ekOAMDWMsIMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAADpy66Ahzr8MGDi64CAAATI8wAADAgMAMAwIApGcCWm59mdP7+/QusCcDxHTh0aNFVYEkJzAAAu9D8LwAH9u1bWD12A1MyAABgwAgz7BBWTwGAxTDCDAAAA0aYSeKmLACA4zHCDAAAA0aYAWCbWb0AdhYjzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAgMAMAAAD1mEGAFbW/JrYcDxGmAEAYEBgBgCAAVMyYIkdPnhw0VXYdPPXdP7+/QusCQCsjxFmAAAYEJgBAGDAlAwAgF1ufjWQA/v2LaweO5URZgAAGBCYAQBgQGAGAIABc5gBWDme7gZshBFmAAAYMMIMAAtk9QJYficcYa6qt1bVg1X1O3Nlp1fVzVX18enrs+b2vb6q7q6qw1V1yVz5S6vqjmnfm6qqNv9y2AyHDx784gsAYNWtZ0rG25JcelTZNUlu6e7zktwyvU9VXZDkiiQvns55c1WdMp3zliRXJTlveh39mQAAsHROOCWju3+tqvYeVXxZkn3T9g1JDiV53VT+zu5+JMknquruJBdV1e8neUZ3fyBJqurtSS5P8r4nfQWwyxjZB4DlcrJzmJ/d3fcnSXffX1VnTuVnJ/ng3HH3TmWfn7aPLl9TVV2V2Wh0nvvc555kFYFlN//Lwfn79y+wJqtJXwuwPpt9099a85J7UL6m7r4+yfVJcuGFFx73uN3EqCKw3VaxrwU4GScbmB+oqrOm0eWzkjw4ld+b5Ny5485Jct9Ufs4a5QAA28o63GzUya7DfFOSK6ftK5O8Z678iqo6raqen9nNfbdO0zcerqqLp9UxXj13DgAALK0TjjBX1S9kdoPfGVV1b5IfSnJdkhur6jVJ7knyyiTp7jur6sYkH03yaJLXdvdj00ddndmKG0/L7GY/N/wBwIJZBxpObD2rZHzncXa9/DjHX5vk2jXKb0vykg3VDgDYFIIxnDxP+gMAWCF+edo4gRkASCJIwfEIzACwiwi9sPkEZgBYMlsdei2rBhsjMDPkSWwAwKoTmAFghzNiDFtLYAaWgr9mwHJZK4TPTw8xV5pVIjADwBI7XjA1qgzbR2Bm6cyPNM7bzaOOx7tmgHlCMiyGwMy28md3AGCnEZhZGOEZgO1idJ4nQ2BmKZiSAAAsK4GZddvoiPBmjyAbkQYAFkFgZlsYQQbYvSwxx24nMPOkLWLkdz0B3Cg0ALAZBGZOihFjAGBVCMwAwKYxPYPdSGCGBTJSDwDLT2BmUwmAAMBu85RFVwAAAJaZEWYAYEuYz7z8/Butj8DMruVBJwCrRwBkK5iSAQAAA0aYgaXjrwMALBOBmZUggAEAJ0tgZuU8Hp4XFZwtvQfbx3xWYDMIzAskOAGwKvzywk7mpj8AABgQmAEAYMCUDFaWGwEBgPUQmAGAHW1+fjRsBVMyAABgQGAGAIABUzJgG1hC8OQtet1sYPNZYm45+Xc5PoEZ4gZAgGUgsLGsBGYAYOkIzywTc5gBAGBAYAYAgAFTMuAox7tBb6Nzm93oB7D5rLnMIgjMsE5uDARYDCGZRROYt5lRRwBg2bnp8kgCM7AjGOEHYFEEZniS/NUAAHY3gRlOgpAMAKtDYAZgV3GDGLDZBGZgxzGfGYDt5MElAAAwIDADAMCAwAwAAAPmMAMAsC6r+kATI8wAADAgMAMAwIApGcCOZok5ALaawLwNPBUOAGDnEpgB2PE83Q/YSgIzAAAbtkorZgjMwK5hPjMAW0FgBnYl4Rlg++z20WaBeYu40Q8AYHcQmAHYkdzoB2yXbQ/MVXVpkoNJTknys9193XbXYSsZWYblY3oGwPZ5/JfZ3TQ1Y1uf9FdVpyT56STfkuSCJN9ZVRdsZx0AAGAjtnuE+aIkd3f37yVJVb0zyWVJPrrN9TgpxxulMqoMO4fR5uW1npuGTMOAneN4/1934sjzdgfms5N8au79vUm+bpvrsKaNhl4hGXa+9fw/XuuXY0H7ydtI8BWSYffaKaG6unv7vlnVK5Nc0t3/cHr/qiQXdff3HHXcVUmumt6en+TwFlTnjCSf3oLP3am0x5G0x5G0xxMW0RbP6+49m/2h29TXJn5+5mmLI2mPI2mPI213exy3r93uwPz1SQ509yXT+9cnSXf/yLZV4om63NbdF273911W2uNI2uNI2uMJ2mLjtNkTtMWRtMeRtMeRlqk9tvWmvyT/I8l5VfX8qvrSJFckuWmb6wAAAOu2rXOYu/vRqvonSf5LZsvKvbW779zOOgAAwEZs+zrM3f3LSX55u7/vGq5fdAWWjPY4kvY4kvZ4grbYOG32BG1xJO1xJO1xpKVpj22dwwwAADvNds9hBgCAHWXlAnNVfU9VHa6qO6vqx+bKX19Vd0/7LllkHbdbVf1AVXVVnTFXtnLtUVU/XlUfq6rfrqpfqqpnzu1bufZIZo+yn6757qq6ZtH12W5VdW5V/WpV3TX1Gfun8tOr6uaq+vj09VmLrusy0t8eS387o7891ir3tzuir+3ulXkl+cYk709y2vT+zOnrBUk+kuS0JM9P8rtJTll0fbepTc7N7CbMTyY5Y5XbI8nfSHLqtP2jSX50xdvjlOlaX5DkS6c2uGDR9drmNjgryddM209P8r+mn4cfS3LNVH7N4z8rXke0nf722DbR3z7RFvrbI9tjpfvbndDXrtoI89VJruvuR5Kkux+cyi9L8s7ufqS7P5Hk7swe470KfjLJDyaZn8y+ku3R3b/S3Y9Obz+Y5JxpeyXbI3OPsu/uP0vy+KPsV0Z339/dvzVtP5zkrsyeWHpZkhumw25IcvlCKrjc9LfH0t9O9LfHWOn+dif0tasWmF+U5Buq6kNV9d+q6mun8rUe2X32ttdum1XVtyX5g+7+yFG7VrI9jvIPkrxv2l7V9ljV615TVe1N8tVJPpTk2d19fzLr6JOcucCqLSv97Rz97ZD+dnWv+xjL2tdu+7JyW62q3p/kL6yx642ZXe+zklyc5GuT3FhVL0hSaxy/K5YPOUF7vCGzP4sdc9oaZbu+Pbr7PdMxb0zyaJJ3PH7aGsfvivY4gVW97mNU1Zcn+cUk39vdn61aq2lWj/72SPrbI+lvN2RVr/sIy9zX7rrA3N3ffLx9VXV1knf1bDLMrVX1hcyeU35vZnPLHndOkvu2tKLb5HjtUVV/ObP5YR+ZfiDPSfJbVXVRVrA9HldVVyZ5RZKXTz8nyS5ujxNY1es+QlV9SWYd+Du6+11T8QNVdVZ3319VZyV58PifsHvpb4+kvz2S/nZDVvW6v2jZ+9pVm5Lx7iTflCRV9aLMJtZ/OrPHc19RVadV1fOTnJfk1kVVcjt09x3dfWZ37+3uvZn9Z/2a7v7fWcH2SGZ3KCd5XZJv6+7/N7drJdsjHmWfmqWbn0tyV3f/xNyum5JcOW1fmeQ92123HeDd0d8m0d+uRX97jJXub3dCX7vrRphP4K1J3lpVv5Pkz5JcOf1We2dV3Zjko5n9aei13f3YAuu5UN29qu3xU5ndmX3zNAr0we7+7lVtj/Yo+yR5WZJXJbmjqj48lb0hyXWZTTF4TZJ7krxyMdVbavrbdVjV/iX62yPob5e/r/WkPwAAGFi1KRkAALAhAjMAAAwIzAAAMCAwAwDAgMAMAAADAjMAAAwIzAAAMCAwAwDAwP8HUBC6MfcsY4gAAAAASUVORK5CYII=\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "mean_std_float = scale_to_mean_std(sample, target_mean=-20, discrete=False)\n",
+    "mean_std_discrete = scale_to_mean_std(sample, target_mean=-20, discrete=True)\n",
+    "\n",
+    "plot_distribution(mean_std_float, mean_std_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e1b6f2bb-0bb3-4839-aee5-07d966924deb",
+   "metadata": {},
+   "source": [
+    "#### Scale to a given range\n",
+    "Both ranges are given"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "id": "bcbd655e-6930-40cd-bcc3-2f0b26d42abb",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "range_float = scale_to_range(sample, discrete=False, target_min=-100, target_max=200)\n",
+    "range_discrete = scale_to_range(sample, discrete=True, target_min=-100, target_max=200)\n",
+    "\n",
+    "plot_distribution(range_float, range_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8ea5f81c-d72c-4924-b031-a0d026fac47f",
+   "metadata": {},
+   "source": [
+    "##### Special case if only one of the ranges is given (negative min)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 19,
+   "id": "cc6c5ba5-1879-46d6-8366-48faeb237f90",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "range_float = scale_to_range(sample, discrete=False, target_min=-100)\n",
+    "range_discrete = scale_to_range(sample, discrete=True, target_min=-100)\n",
+    "\n",
+    "plot_distribution(range_float, range_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "15f0c099-9ca9-49ee-8535-9dcb2935e027",
+   "metadata": {},
+   "source": [
+    "##### Special case if only one of the ranges is given (small max)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 20,
+   "id": "d16d1590-0c9b-4b82-8196-770d0e30a238",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "range_float = scale_to_range(sample, discrete=False, target_max=10)\n",
+    "range_discrete = scale_to_range(sample, discrete=True, target_max=10)\n",
+    "\n",
+    "plot_distribution(range_float, range_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "de68a66e-a980-41f4-be8a-76c1c22a1665",
+   "metadata": {},
+   "source": [
+    "##### Special case for 0 and 1"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 21,
+   "id": "fa96ae3d-d85f-4a9b-87e0-adfaf6f87bd7",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "range_float = scale_to_range(sample, discrete=False, target_min=0, target_max=1)\n",
+    "range_discrete = scale_to_range(sample, discrete=True, target_min=0, target_max=1)\n",
+    "\n",
+    "plot_distribution(range_float, range_discrete)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3726793b-93d0-4d75-8eb2-4139b319ade8",
+   "metadata": {},
+   "source": [
+    "#### Scale to a target sum\n",
+    "Given a large enough sum (corresponding to the 10_000 samples!), it's OK. But at smaller values zeroes will dominate."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 22,
+   "id": "2a363f73-cb18-477f-a390-0e0956f619c9",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 864x432 with 2 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "sum_float = scale_to_sum(sample, discrete=False, target_sum=1_000_000)\n",
+    "sum_discrete = scale_to_sum(sample, discrete=True, target_sum=1_000_000)\n",
+    "\n",
+    "plot_distribution(sum_float, sum_discrete)"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/environment.yml b/environment.yml
deleted file mode 100644
index 1835aa4..0000000
--- a/environment.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: exhibit
-channels:
-  - defaults
-dependencies:
-  - dill=0.3.7
-  - numpy=1.25.2
-  - pandas=2.0.3
-  - pip=23.2.1
-  - python=3.10.13
-  - pyyaml=6.0.1
-  - scipy=1.11.1
-  - shapely=2.0.1
-  - sqlalchemy=1.4.39
-  - pip:
-      - h3==3.7.6
-      - pyarrow==14.0.1
-      - sql-metadata==2.9.0
diff --git a/exhibit/core/constraints.py b/exhibit/core/constraints.py
index 42ebe6f..4f3cf79 100644
--- a/exhibit/core/constraints.py
+++ b/exhibit/core/constraints.py
@@ -210,10 +210,20 @@ def process_custom_constraints(self, custom_constraints):
                         _kwargs = kwargs_dict.get(action, {})
                         _kwargs.update(spec_action_kwargs)
 
-                        # overwrite the original DF row IDs with the adjusted ones
-                        output_df.loc[cc_filter_idx] = action_func(
+                        # because the result of the action can be a different dtype compared
+                        # to the original (like int to float, particularly involving NULLs)
+                        # we need to capture the resultant dtype first, and then cast the 
+                        # original df to match it to avoid Pandas errors.
+                        action_df = action_func(
                             output_df, cc_filter_idx, target_str,
                             cc_partitions, **_kwargs)
+                        
+                        action_dtypes = action_df.dtypes
+
+                        output_df = output_df.astype(action_dtypes)
+        
+                        # overwrite the original DF row IDs with the adjusted ones
+                        output_df.loc[cc_filter_idx] = action_df
 
         return output_df
 
@@ -1231,12 +1241,13 @@ def shift_distribution(
 
                 final_result.append(new_series)
                 continue
-
+        
+        # return the DF, matching the dtypes of the original (relevant for dates)
         new_df = pd.concat(
             final_result + 
             [df.loc[filter_idx, [x for x in df.columns if x not in target_cols]]],
             axis=1
-        ).reindex(columns=df.columns)
+        ).reindex(columns=df.columns).astype(df.dtypes)
 
         return new_df
 
diff --git a/exhibit/core/exhibit.py b/exhibit/core/exhibit.py
index 72f4012..0b0a397 100644
--- a/exhibit/core/exhibit.py
+++ b/exhibit/core/exhibit.py
@@ -344,9 +344,10 @@ def execute_spec(self):
                             )
             
             if col in geo_action_targets:
-                # add placeholders to avoid errors when generating missing data
+                # add float placeholders to avoid errors when generating missing data
                 geo_cols = [f"{col}_latitude", f"{col}_longitude"]
-                anon_df[geo_cols] = 0
+                # use 0.0 to ensure column dtype is float so that we could null them later
+                anon_df[geo_cols] = 0.0
                 continue
 
             h3_table_name = self.spec_dict["columns"][col]["h3_table"]
@@ -444,7 +445,7 @@ def execute_spec(self):
                         anon_df[derived_col] = generate_derived_column(anon_df, derived_def)
                         break             
             # change the missing data placeholder back to NAs
-            anon_df.loc[:, cat_cols] = anon_df.loc[:, cat_cols].applymap(
+            anon_df.loc[:, cat_cols] = anon_df.loc[:, cat_cols].map(
             lambda x: np.nan if x == MISSING_DATA_STR else x)
 
         #8) GENERATE DERIVED COLUMNS IF ANY ARE SPECIFIED
diff --git a/exhibit/core/generate/categorical.py b/exhibit/core/generate/categorical.py
index 861e673..7be717b 100644
--- a/exhibit/core/generate/categorical.py
+++ b/exhibit/core/generate/categorical.py
@@ -1,662 +1,662 @@
-'''
-Methods to generate categorical columns / values
-'''
-
-# Standard library imports
-from collections import namedtuple
-from itertools import chain
-import warnings
-
-# External library imports
-import pandas as pd
-import numpy as np
-from sql_metadata import Parser
-from pandas.api.types import is_numeric_dtype, is_datetime64_dtype
-
-# Exhibit imports
-from ..constants import ORIGINAL_VALUES_REGEX, ORIGINAL_VALUES_PAIRED
-from ..utils import get_attr_values, shuffle_data
-from ..sql import query_exhibit_database, check_table_exists, execute_sql, create_temp_table
-from ..linkage.hierarchical import generate_linked_anon_df
-from ..linkage.matrix import generate_user_linked_anon_df
-from .regex import generate_regex_column
-
-# EXPORTABLE METHODS
-# ==================
-class CategoricalDataGenerator:
-    '''
-    Although this class is pretty bare, it still helps avoid passing
-    the same variables through functions and also mirrors the setup
-    for generation of linked data.
-
-    One area that potentially needs looking at is if the user makes
-    manual changes to column values that were initially put into SQL
-    (where uniques > inline_limit) - for now, this works only for linked data.
-    '''
-
-    def __init__(self, spec_dict, core_rows, anon_df=None):
-        '''
-        This class is covering the entire spec_dict as far as the 
-        generation of non-numerical data is concerned.
-        '''
-        
-        self.spec_dict = spec_dict
-        self.rng = spec_dict["_rng"]
-        self.num_rows = core_rows
-        self.fixed_anon_sets = ["random", "mountains", "patients", "birds", "dates"]
-        # we need UUID dataset (if it exists) for possible conditional SQL that
-        # references already-generated columns in the spec
-        self.generated_dfs = []
-        self.anon_df = anon_df
-        
-        (self.all_cols,
-         self.complete_cols,
-         self.paired_cols,
-         self.skipped_cols) = self._get_column_types()
-
-    def generate(self):
-        '''
-        Brings together all the components of non-numerical data generation.
-
-        Returns
-        -------
-        A dataframe with all categorical columns
-        '''
-
-        #1) GENERATE LINKED DFs FROM EACH LINKED COLUMNS GROUP
-        for linked_group in (self.spec_dict.get("linked_columns") or []):
-            
-            # zero-numbered linked group is reserved for user-defined groupings
-            if linked_group[0] == 0:
-
-                u_linked_df = generate_user_linked_anon_df(
-                    spec_dict=self.spec_dict,
-                    linked_cols=linked_group[1],
-                    num_rows=self.num_rows
-                )
-
-                self.generated_dfs.append(u_linked_df)
-
-            else:
-
-                linked_df = generate_linked_anon_df(
-                    spec_dict=self.spec_dict,
-                    linked_group=linked_group,
-                    num_rows=self.num_rows)
-
-                self.generated_dfs.append(linked_df)
-
-        #2) GENERATE NON-LINKED DFs
-        for col in [col for col in self.all_cols if col not in self.skipped_cols]:
-            s = self._generate_anon_series(col)
-            self.generated_dfs.append(s)
-
-        #3) CONCAT GENERATED DFs AND SERIES
-        temp_anon_df = pd.concat(self.generated_dfs, axis=1)
-
-        #4) GENERATE SERIES WITH "COMPLETE", CROSS-JOINED COLUMNS
-        complete_series = []
-
-        # Complete series can sort the data again
-        for col in self.complete_cols:
-            s = self._generate_complete_series(col)
-            #paired columns return None
-            if not s is None:
-                complete_series.append(s)
-        
-        #5) OUTER JOIN
-        temp_anon_df["key"] = 1
-
-        for s in complete_series:
-
-            temp_anon_df = pd.merge(
-                temp_anon_df,
-                pd.DataFrame(s).assign(key=1),
-                how="outer",
-                on="key"
-            )
-        
-        #6) TIDY UP
-        anon_df = temp_anon_df.drop("key", axis=1)
-
-        return anon_df
-
-    def _generate_timeseries(self, col_name, complete=False):
-        '''
-        Basic generator of randomised / complete timeseries data
-
-        Parameters:
-        ----------
-        col_name  : str
-            time column to generate (type checks are made upstream)
-        complete  : boolean
-            if timeseries is meant to be "complete", return full series
-            without picking N=num_rows random values from the pool
-
-        Returns:
-        --------
-        pd.Series
-        '''
-
-        # see which date parameters we have access to
-        start = self.spec_dict["columns"][col_name].get("from", None)
-        end = self.spec_dict["columns"][col_name].get("to", None)
-        
-        # frequency and periods are always required
-        freq = self.spec_dict["columns"][col_name]["frequency"]
-        periods = self.spec_dict["columns"][col_name]["uniques"]
-
-        # if we have both start and end, we generate all values in-between and pick the 
-        # dates at random to match the number of periods, without repeats
-        if start is not None and end is not None:
-
-            all_pos_dates = pd.date_range(start=start, end=end, freq=freq)
-            # when the number of requested periods is greater than the total possible
-            # range between from and to, given the frequency, we issue a warning, then
-            # omit the date_to and generate N=periods unique dates from date_from.
-            if len(all_pos_dates) < periods:
-                warnings.warn(
-                    f"The number of unique dates at frequency {freq} between {start} "
-                    f"and {end} is smaller than the number of requested periods"
-                    f"({periods}). The date_to parameter will be ignored.",
-                    RuntimeWarning
-                    )
-                all_pos_dates = pd.date_range(start=start, periods=periods, freq=freq)
-
-            all_pos_dates = self.rng.choice(all_pos_dates, periods, replace=False)
-
-        else:
-            # one of the start / end is None
-            all_pos_dates = pd.date_range(
-                start=start, end=end, periods=periods, freq=freq)
-
-        if complete:
-            return pd.Series(all_pos_dates, name=col_name)
-        
-        random_dates = self.rng.choice(all_pos_dates, self.num_rows)
-
-        return shuffle_data(pd.Series(random_dates, name=col_name))
-
-    def _generate_anon_series(self, col_name):
-        '''
-        Generate basic categorical series anonymised according to user input.
-
-        Note that in all cases except external tables, the final series is shuffled
-        and index reset. Series generated from external tables are an exception because
-        their values are linked to columns that have already been generated.
-
-        The code can take different paths depending on these things: 
-        - whether a the anonymising method is set to random or a custom set
-        - whether the number of unique values exceeds the threshold
-        - whether the column has any paired columns
-
-        The paths differ primarily in terms of where the data sits: as part
-        of the spec in original_values or in exhibit DB.
-
-        Things are further complicated if users want to use a single column
-        from an anonymising table, like mountains.peak
-
-        Parameters:
-        -----------
-        col_name : str
-            column name to process & anonymise
-
-        Returns:
-        -------
-        Pandas Series object or a Dataframe
-        '''
-
-        col_attrs = self.spec_dict["columns"][col_name]
-        col_type = col_attrs["type"]
-        
-        # capture categorical-only information, with fallback for date columns
-        paired_cols = col_attrs.get("paired_columns", None)
-        orig_vals = col_attrs.get("original_values", None)
-        target_uniques = col_attrs.get("uniques", None)
-
-        # typically, only categorical columns will have an anonymising set, but time
-        # columns can use it for SQL to pull conditional values from external table
-        # ignoring the standard date genderation parameters, like from / to.        
-        anon_set = col_attrs.get("anonymising_set", None)
-
-        # Users can pass custom functions to generate categorical / date columns
-        if callable(anon_set):
-            return self._generate_using_custom_function(col_name, anon_set)
-
-        # check if the anonymising set is a SQL statement starting with SELECT
-        # note that for dates, all other parameters, like from / to will be ignored
-        if anon_set is not None and anon_set.strip().upper()[:6] == "SELECT":
-            return self._generate_using_external_table(col_name, anon_set)
-
-        # normal date columns generated using from / to / number of uniques
-        if col_type == "date":
-            return self._generate_timeseries(col_name, complete=False)  
-
-        # generate values based on a regular expression specified in the anonymising_set
-        if isinstance(orig_vals, str) and orig_vals == ORIGINAL_VALUES_REGEX:
-            return generate_regex_column(
-                anon_set, col_name, self.num_rows, target_uniques)
-
-        # values were stored in SQL; randomise based on uniform distribution
-        if col_attrs["uniques"] > self.spec_dict["metadata"]["inline_limit"]:
-            return self._generate_from_sql(col_name, col_attrs)
-
-        # we have access to original_values and the paths are dependant on anon_set
-        # take every row except last which is reserved for Missing data
-        col_df = col_attrs["original_values"].iloc[:-1, :]
-        col_prob = np.array(col_df["probability_vector"]).astype(float)
-
-        if col_prob.sum() != 1:
-            col_prob /= col_prob.sum()
-
-        if anon_set == "random": 
-
-            col_values = col_df[col_name].to_list()
-
-            original_series = pd.Series(
-                data=self.rng.choice(a=col_values, size=self.num_rows, p=col_prob),
-                name=col_name)
-
-            if paired_cols:
-                paired_df = (
-                    col_df[[col_name] + [f"paired_{x}" for x in paired_cols]]
-                        .rename(columns=lambda x: x.replace("paired_", ""))
-                )
-
-                return shuffle_data(
-                    pd.merge(original_series, paired_df, how="left", on=col_name))
-
-            return shuffle_data(original_series)
-
-        # finally, if we have original_values, but anon_set is not random
-        # we pick the N distinct values from the anonymysing set, replace
-        # the original values + paired column values in the original_values
-        # DATAFRAME, making sure the changes happen in-place which means
-        # that downstream, the weights table will be built based on the
-        # modified "original_values" dataframe.
-
-        sql_df = self._generate_from_sql(col_name, col_attrs, complete=True)
-
-        # includes Missing data row as opposed to col_df which doesn't
-        orig_df = col_attrs["original_values"]
-
-        # missing data is the last row
-        repl = sql_df[col_name].unique()
-        aliases = dict(zip(orig_df[col_name].values[:-1], repl))
-        aliased_df = orig_df.applymap(lambda x: aliases.get(x, x))
-        self.spec_dict["columns"][col_name]["original_values"] = aliased_df
-
-        # we ignore Missing data probability when we originally create the variable
-        idx = self.rng.choice(a=len(sql_df), p=col_prob, size=self.num_rows)
-        anon_list = [sql_df.iloc[x, :].values for x in idx]
-        anon_df = pd.DataFrame(columns=sql_df.columns, data=anon_list)
-
-        return shuffle_data(anon_df)
-        
-    def _generate_from_sql(self, col_name, col_attrs, complete=False, db_path=None):
-        '''
-        Whatever the anonymising method, if a column has more unique values than
-        allowed by the inline_limit parameter, it will be put into SQLite3 db.
-        '''
-
-        anon_set = col_attrs["anonymising_set"]
-        uniques = col_attrs["uniques"]
-        paired_cols = col_attrs["paired_columns"] or []
-
-        #1) QUERY SQL TO GET VALUES USED TO BUILD THE DATAFRAME
-        if anon_set == "random":
-
-            safe_col_name = col_name.replace(" ", "$")
-            table_name = f"temp_{self.spec_dict['metadata']['id']}_{safe_col_name}"
-            sql_df = query_exhibit_database(
-                table_name, exclude_missing=True, db_path=db_path)
-
-        else:
-            table_name, *sql_column = anon_set.split(".")
-            sql_df = query_exhibit_database(table_name, sql_column, uniques)
-
-        # if sql df is an anonymising set with different column names, like mountaints,
-        # we want to rename them to the actual column names used in the spec;
-        # alternatively, if the sql df is a lookup and column there match the spec, we
-        # make sure to take those columns that match.
-        if set([col_name] + paired_cols).issubset(set(sql_df.columns)):
-            sql_df = sql_df[[col_name] + paired_cols]
-
-        # rename sql_df columns to be same as original + paired; zip is 
-        # only going to pair up columns up to the shorter list!
-        sql_df.rename(
-            columns=dict(zip(
-                sql_df.columns,
-                [col_name] + paired_cols
-            )),
-            inplace=True
-        )
-
-        #2) GENERATE ANONYMISED ROWS
-        if complete:
-            anon_df = sql_df.drop(columns="probability_vector", errors="ignore")
-        else:
-            if "probability_vector" in sql_df.columns:
-                probs = sql_df["probability_vector"].astype(float).values
-                probs = probs / probs.sum()
-                sql_df.drop(columns="probability_vector", inplace=True)
-                idx = self.rng.choice(a=len(sql_df), p=probs, size=self.num_rows)
-            else:
-                idx = self.rng.choice(len(sql_df), self.num_rows)
-
-            anon_list = [sql_df.iloc[x, :].values for x in idx]
-            anon_df = pd.DataFrame(columns=sql_df.columns, data=anon_list)
-
-        #3) HANDLE MISSING PAIRED COLUMNS IN SQL
-        # if the column has paired columns and a non-random anonymising set,
-        # the anonymising set must also provide the paired columns or the same
-        # values will be used for the original + paired columns
-        missing_paired_cols = set(paired_cols) - set(sql_df.columns[1:])
-
-        if missing_paired_cols:
-            missing_df = pd.DataFrame(
-                data=zip(*[anon_df[col_name]] * len(missing_paired_cols)),
-                # sets are no longer allowed as column names
-                columns=list(missing_paired_cols)
-            )
-
-            anon_df = pd.concat([anon_df, missing_df], axis=1)
-
-        return shuffle_data(anon_df)
-
-    def _generate_complete_series(self, col_name):
-        '''
-        This function doesn't take num_rows argument because
-        we are always generating the full number of rows
-        for this column as specified in the spec.
-
-        Function path depends on the column type: date or categorical
-
-        Returns
-        -------
-        pd.Series for non-paired columns and pd.DataFrame for pairs
-
-        For now, the function doesn't support columns where values are
-        stored in the DB because the number of their uniques exceeds
-        category threshold or if they are anonymised using a set from DB.
-        '''
-        
-        col_attrs = self.spec_dict["columns"][col_name]
-        
-        if col_attrs["type"] == "date":
-
-            return self._generate_timeseries(col_name, complete=True) 
-        
-        # if paired column, skip, and add pairs as part of parent column's processing
-        if col_name in self.paired_cols:
-            return None
-
-        # if column has paired columns, return a dataframe with it + paired cols
-        paired_cols = col_attrs["paired_columns"]
-
-        # all cat. columns have a missing data placeholder as -1 row so we exclude it
-        if paired_cols:
-            paired_complete_df = (
-                col_attrs["original_values"].iloc[:-1, 0:len(paired_cols)+1])
-            paired_complete_df.rename(
-                columns=lambda x: x.replace("paired_", ""), inplace=True)
-
-            return paired_complete_df
-
-        return pd.Series(col_attrs["original_values"].iloc[:-1, 0], name=col_name)
-
-    def _get_column_types(self):
-        '''
-        Convenience function to categorise columns into 4 types:
-            - nested linked columns (generated separately as part of linkage.py)
-            - complete columns - all values are used
-            - columns where original values are paired with a "main" column
-
-        All of the above are treated in a special way either in a separate
-        generation routine (like linked columns) or are generated as a
-        by-product of another routine (like paired columns). Columns that remain,
-        are generated in a "normal" way as part of this module.
-
-        Returns
-        -------
-        namedtuple("Columns", ["all", "complete", "paired", "skipped"])
-        '''
-
-        Columns = namedtuple("Columns", ["all", "complete", "paired", "skipped"])
-
-        # there might be cases when you want to generate just the date columns or just
-        # the categorical columns so they might be missing from the metadata section
-        all_cols = (
-            (self.spec_dict["metadata"].get("categorical_columns", [])) +
-            (self.spec_dict["metadata"].get("date_columns", []))
-        )
-        
-        nested_linked_cols = [
-            sublist for n, sublist in (self.spec_dict.get("linked_columns") or [])
-            ]
-
-        complete_cols = [c for c, v in get_attr_values(
-            self.spec_dict,
-            "cross_join_all_unique_values",
-            col_names=True, 
-            types=["categorical", "date"]) if v]
-
-        list_of_orig_val_tuples = get_attr_values(
-            self.spec_dict,
-            "original_values",
-            col_names=True,
-            types=["categorical", "date"])
-
-        paired_cols = [
-            k for k, v in list_of_orig_val_tuples if str(v) == ORIGINAL_VALUES_PAIRED]
-
-        skipped_cols = (
-            list(chain.from_iterable(nested_linked_cols)) +
-            complete_cols +
-            paired_cols
-        )
-
-        column_types = Columns(all_cols, complete_cols, paired_cols, skipped_cols)
-
-        return column_types
-
-    def _generate_using_external_table(self, col_name, anon_set):
-        '''
-        We assume that the aliased column is the one you want to pick the values from
-        and the rest of the columns in the select statement are going to be the join
-        keys.
-        '''
-
-        parser = Parser(anon_set)
-        sql_tables = parser.tables
-        aliased_columns = parser.columns_aliases_names
-        source_table_id = self.spec_dict["metadata"]["id"]
-    
-        if len(aliased_columns) != 1 or aliased_columns[0] != col_name:
-            raise RuntimeError(
-                f"Please make sure the SQL SELECT statement in {col_name}'s "
-                f"anonymising_set includes exactly one aliased column named {col_name}."
-            )
-        
-        # "join" columns are all non-aliased columns from the source table
-        # "join" here refers to joining back the data from the SQL statment to the
-        # original source data, not any join columns that are part of the JOIN section
-        # of SQL proper.
-
-        join_columns = []
-        for qualified_column in parser.columns_dict["select"]:
-            table, column = qualified_column.split(".")
-            if table == f"temp_{source_table_id}" and column != col_name:
-                join_columns.append(column)
-
-        # "source" table aka existing table is always put into exhibit DB, but if 
-        # SQL is trying to reference an external table, we should check if it exists
-        ext_tables = [
-            t for t in sql_tables if t not in ["temp_original_values", f"temp_{source_table_id}"]
-        ]
-
-        # check the "external" table is in exhibit.db
-        for ext_table in ext_tables:
-            if not check_table_exists(ext_table):
-                raise RuntimeError(
-                    f"Please make sure that {ext_table} used in the anonymising_set SQL"
-                    f" for column {col_name} exists in the Exhibit database."
-                )
-        
-        # insert the dataframe generated so far into the DB; we make sure to drop
-        # duplicates in case user didn't specify DISTINCT in his SQL query;
-        # the anon_df would typically be from UUIDs that are generated before
-        # categorical columns.
-
-        # self.anon_df is what is generated BEFORE categorical columns, e.g UUID columns
-        if self.anon_df is None or self.anon_df.empty:
-            # self.generated_dfs has cat. columns generated BEFORE this particular column
-            if not self.generated_dfs: #pragma: no cover
-                existing_data = pd.DataFrame()
-            else:
-                existing_data = pd.concat(self.generated_dfs, axis=1)
-        else:
-            existing_data = pd.concat(self.generated_dfs + [self.anon_df], axis=1)
-
-        # for convenience, we can reference original_values as a table - this could be 
-        # original_values as they appear in the SPEC or in the SQL (not implemented yet)
-        if "temp_original_values" in sql_tables:
-            ov_df = self.spec_dict["columns"][col_name]["original_values"][[col_name]]
-            create_temp_table(
-                table_name="temp_original_values",
-                col_names=[col_name],
-                data=ov_df
-            )
-
-        # ensure the data going into DB is processed identically for join keys
-        for col in join_columns:
-            if is_numeric_dtype(existing_data[col]):
-                existing_data[col] = existing_data[col].astype(float)
-            elif is_datetime64_dtype(existing_data[col]):
-                existing_data[col] = existing_data[col].dt.strftime("%Y-%m-%d")
-            else:
-                existing_data[col] = existing_data[col].astype(str).str.strip()
-
-        # dropping duplicates is a filter operation (even though it returns new data)
-        # unless we make an explicit copy of the de-duplicated dataframe, Pandas will 
-        # trigger SettingWithCopy warning when trying to change any values.
-        existing_data_distinct = existing_data.drop_duplicates(subset=join_columns).copy()
-        existing_data_cols = list(existing_data.columns)
-
-        # this function converts list of tuples into a dataframe anyway
-        create_temp_table(
-            table_name=f"temp_{source_table_id}",
-            col_names=existing_data_cols,
-            data=existing_data_distinct
-        )
-
-        # run the SQL from anon_set; note that the type of SQL query we'll likely see 
-        # will be a cross-join (e.g. dates) so any speed optimisations would be welcome
-        result = execute_sql(anon_set)
-
-        # create the dataframe with SQL data
-        sql_df = pd.DataFrame(data=result, columns=join_columns + aliased_columns)
-
-        # ensure that the column of interest (the one we're potentially matching to original
-        # values) is typed to string - and not datetime or int, coming out of SQL. We will
-        # convert to datetime at the end, if that's what the type in the spec is.
-        sql_df[col_name] = sql_df[col_name].astype("str")
-
-        # get the probabilities for the selected column in the external table
-        # at the level of the join key - use a hash for the combination of columns!
-
-        # Rather than use existing probabilities from the spec, treat them as a weight 
-        # and apply them to the conditional, per-join key probabilities from external
-        # table.
-        probas = {}
-        orig_vals = None
-
-        try:
-            orig_vals = self.spec_dict["columns"][col_name]["original_values"]
-            if isinstance(orig_vals, pd.DataFrame):
-                orig_vals = orig_vals.set_index(col_name)
-        # if we don't have original_values in the column spec, it's a date
-        except KeyError:
-            pass
-
-        groups = sql_df.groupby(join_columns)
-        for i, group in groups:
-
-            total_count = len(group)
-            proba_arr = (group
-                            .value_counts()
-                            .apply(lambda x: 0 if x == 0 else max(0.001, x / total_count))
-                            .reset_index(level=col_name)
-                            .to_numpy(dtype="str")
-                            )
-            a, p = np.split(proba_arr, 2, axis=1)
-            a = a.flatten()
-            p = p.flatten().astype(float)
-
-            if orig_vals is not None:
-                for j, val in enumerate(a):
-                    if val in orig_vals.index:
-                        p_weight = float(orig_vals.loc[val, "probability_vector"])
-                        p[j] = p[j] * p_weight
-
-            # enusre p sums up to 1
-            p = p * (1 / sum(p))
-            probas[i[0]] = (a, p)
-
-        # take the data generated so far and generate appropriate values based on key
-        groups = existing_data.groupby(join_columns).groups
-        temp_result = []
-
-        for group_key, group_index in groups.items():
-            # if the key is missing, then the SQL filtered out the data for that key
-            # having a COALESCE in SQL would fix it, but in case it's also missing, 
-            # we try to catch this edge case in code as well. 
-            try:
-                new_data = self.rng.choice(
-                    a=probas[group_key][0], p=probas[group_key][1], size=len(group_index))
-            except KeyError: #pragma: no cover
-                new_data = [np.nan] * len(group_index)
-    
-            temp_result.append(pd.Series(data=new_data, index=group_index, name=col_name))
-
-        final_result = pd.concat(temp_result)
-
-        # ensure we return the correct type for date columns
-        col_type = self.spec_dict["columns"][col_name]["type"]
-        if col_type == "date":
-            final_result = final_result.astype("datetime64[ns]")
-
-        return final_result
-    
-    def _generate_using_custom_function(self, col_name, anon_set):
-        '''
-        _summary_
-
-        Parameters
-        ----------
-        col_name : _type_
-            _description_
-        anon_set : _type_
-            _description_
-        '''
-        # self.anon_df is what is generated BEFORE categorical columns, e.g UUID columns
-        if self.anon_df is None or self.anon_df.empty:
-            # self.generated_dfs has cat. columns generated BEFORE this particular column
-            if not self.generated_dfs:
-                existing_data = pd.DataFrame()
-            else:
-                existing_data = pd.concat(self.generated_dfs, axis=1)
-        else: #pragma: no cover
-            existing_data = pd.concat(self.generated_dfs + [self.anon_df], axis=1)
-
-        if existing_data.empty:
-            result = pd.Series(
-                data=[anon_set(pd.Series) for _ in range(self.num_rows)],
-                name=col_name
-            )
-            return result
-
-        result = existing_data.apply(anon_set, axis=1)
-        result.name = col_name
-
-        return result
+'''
+Methods to generate categorical columns / values
+'''
+
+# Standard library imports
+from collections import namedtuple
+from itertools import chain
+import warnings
+
+# External library imports
+import pandas as pd
+import numpy as np
+from sql_metadata import Parser
+from pandas.api.types import is_numeric_dtype, is_datetime64_dtype
+
+# Exhibit imports
+from ..constants import ORIGINAL_VALUES_REGEX, ORIGINAL_VALUES_PAIRED
+from ..utils import get_attr_values, shuffle_data
+from ..sql import query_exhibit_database, check_table_exists, execute_sql, create_temp_table
+from ..linkage.hierarchical import generate_linked_anon_df
+from ..linkage.matrix import generate_user_linked_anon_df
+from .regex import generate_regex_column
+
+# EXPORTABLE METHODS
+# ==================
+class CategoricalDataGenerator:
+    '''
+    Although this class is pretty bare, it still helps avoid passing
+    the same variables through functions and also mirrors the setup
+    for generation of linked data.
+
+    One area that potentially needs looking at is if the user makes
+    manual changes to column values that were initially put into SQL
+    (where uniques > inline_limit) - for now, this works only for linked data.
+    '''
+
+    def __init__(self, spec_dict, core_rows, anon_df=None):
+        '''
+        This class is covering the entire spec_dict as far as the 
+        generation of non-numerical data is concerned.
+        '''
+        
+        self.spec_dict = spec_dict
+        self.rng = spec_dict["_rng"]
+        self.num_rows = core_rows
+        self.fixed_anon_sets = ["random", "mountains", "patients", "birds", "dates"]
+        # we need UUID dataset (if it exists) for possible conditional SQL that
+        # references already-generated columns in the spec
+        self.generated_dfs = []
+        self.anon_df = anon_df
+        
+        (self.all_cols,
+         self.complete_cols,
+         self.paired_cols,
+         self.skipped_cols) = self._get_column_types()
+
+    def generate(self):
+        '''
+        Brings together all the components of non-numerical data generation.
+
+        Returns
+        -------
+        A dataframe with all categorical columns
+        '''
+
+        #1) GENERATE LINKED DFs FROM EACH LINKED COLUMNS GROUP
+        for linked_group in (self.spec_dict.get("linked_columns") or []):
+            
+            # zero-numbered linked group is reserved for user-defined groupings
+            if linked_group[0] == 0:
+
+                u_linked_df = generate_user_linked_anon_df(
+                    spec_dict=self.spec_dict,
+                    linked_cols=linked_group[1],
+                    num_rows=self.num_rows
+                )
+
+                self.generated_dfs.append(u_linked_df)
+
+            else:
+
+                linked_df = generate_linked_anon_df(
+                    spec_dict=self.spec_dict,
+                    linked_group=linked_group,
+                    num_rows=self.num_rows)
+
+                self.generated_dfs.append(linked_df)
+
+        #2) GENERATE NON-LINKED DFs
+        for col in [col for col in self.all_cols if col not in self.skipped_cols]:
+            s = self._generate_anon_series(col)
+            self.generated_dfs.append(s)
+
+        #3) CONCAT GENERATED DFs AND SERIES
+        temp_anon_df = pd.concat(self.generated_dfs, axis=1)
+
+        #4) GENERATE SERIES WITH "COMPLETE", CROSS-JOINED COLUMNS
+        complete_series = []
+
+        # Complete series can sort the data again
+        for col in self.complete_cols:
+            s = self._generate_complete_series(col)
+            #paired columns return None
+            if not s is None:
+                complete_series.append(s)
+        
+        #5) OUTER JOIN
+        temp_anon_df["key"] = 1
+
+        for s in complete_series:
+
+            temp_anon_df = pd.merge(
+                temp_anon_df,
+                pd.DataFrame(s).assign(key=1),
+                how="outer",
+                on="key"
+            )
+        
+        #6) TIDY UP
+        anon_df = temp_anon_df.drop("key", axis=1)
+
+        return anon_df
+
+    def _generate_timeseries(self, col_name, complete=False):
+        '''
+        Basic generator of randomised / complete timeseries data
+
+        Parameters:
+        ----------
+        col_name  : str
+            time column to generate (type checks are made upstream)
+        complete  : boolean
+            if timeseries is meant to be "complete", return full series
+            without picking N=num_rows random values from the pool
+
+        Returns:
+        --------
+        pd.Series
+        '''
+
+        # see which date parameters we have access to
+        start = self.spec_dict["columns"][col_name].get("from", None)
+        end = self.spec_dict["columns"][col_name].get("to", None)
+        
+        # frequency and periods are always required
+        freq = self.spec_dict["columns"][col_name]["frequency"]
+        periods = self.spec_dict["columns"][col_name]["uniques"]
+
+        # if we have both start and end, we generate all values in-between and pick the 
+        # dates at random to match the number of periods, without repeats
+        if start is not None and end is not None:
+
+            all_pos_dates = pd.date_range(start=start, end=end, freq=freq)
+            # when the number of requested periods is greater than the total possible
+            # range between from and to, given the frequency, we issue a warning, then
+            # omit the date_to and generate N=periods unique dates from date_from.
+            if len(all_pos_dates) < periods:
+                warnings.warn(
+                    f"The number of unique dates at frequency {freq} between {start} "
+                    f"and {end} is smaller than the number of requested periods"
+                    f"({periods}). The date_to parameter will be ignored.",
+                    RuntimeWarning
+                    )
+                all_pos_dates = pd.date_range(start=start, periods=periods, freq=freq)
+
+            all_pos_dates = self.rng.choice(all_pos_dates, periods, replace=False)
+
+        else:
+            # one of the start / end is None
+            all_pos_dates = pd.date_range(
+                start=start, end=end, periods=periods, freq=freq)
+
+        if complete:
+            return pd.Series(all_pos_dates, name=col_name)
+        
+        random_dates = self.rng.choice(all_pos_dates, self.num_rows)
+
+        return shuffle_data(pd.Series(random_dates, name=col_name))
+
+    def _generate_anon_series(self, col_name):
+        '''
+        Generate basic categorical series anonymised according to user input.
+
+        Note that in all cases except external tables, the final series is shuffled
+        and index reset. Series generated from external tables are an exception because
+        their values are linked to columns that have already been generated.
+
+        The code can take different paths depending on these things: 
+        - whether a the anonymising method is set to random or a custom set
+        - whether the number of unique values exceeds the threshold
+        - whether the column has any paired columns
+
+        The paths differ primarily in terms of where the data sits: as part
+        of the spec in original_values or in exhibit DB.
+
+        Things are further complicated if users want to use a single column
+        from an anonymising table, like mountains.peak
+
+        Parameters:
+        -----------
+        col_name : str
+            column name to process & anonymise
+
+        Returns:
+        -------
+        Pandas Series object or a Dataframe
+        '''
+
+        col_attrs = self.spec_dict["columns"][col_name]
+        col_type = col_attrs["type"]
+        
+        # capture categorical-only information, with fallback for date columns
+        paired_cols = col_attrs.get("paired_columns", None)
+        orig_vals = col_attrs.get("original_values", None)
+        target_uniques = col_attrs.get("uniques", None)
+
+        # typically, only categorical columns will have an anonymising set, but time
+        # columns can use it for SQL to pull conditional values from external table
+        # ignoring the standard date genderation parameters, like from / to.        
+        anon_set = col_attrs.get("anonymising_set", None)
+
+        # Users can pass custom functions to generate categorical / date columns
+        if callable(anon_set):
+            return self._generate_using_custom_function(col_name, anon_set)
+
+        # check if the anonymising set is a SQL statement starting with SELECT
+        # note that for dates, all other parameters, like from / to will be ignored
+        if anon_set is not None and anon_set.strip().upper()[:6] == "SELECT":
+            return self._generate_using_external_table(col_name, anon_set)
+
+        # normal date columns generated using from / to / number of uniques
+        if col_type == "date":
+            return self._generate_timeseries(col_name, complete=False)  
+
+        # generate values based on a regular expression specified in the anonymising_set
+        if isinstance(orig_vals, str) and orig_vals == ORIGINAL_VALUES_REGEX:
+            return generate_regex_column(
+                anon_set, col_name, self.num_rows, target_uniques)
+
+        # values were stored in SQL; randomise based on uniform distribution
+        if col_attrs["uniques"] > self.spec_dict["metadata"]["inline_limit"]:
+            return self._generate_from_sql(col_name, col_attrs)
+
+        # we have access to original_values and the paths are dependant on anon_set
+        # take every row except last which is reserved for Missing data
+        col_df = col_attrs["original_values"].iloc[:-1, :]
+        col_prob = np.array(col_df["probability_vector"]).astype(float)
+
+        if col_prob.sum() != 1:
+            col_prob /= col_prob.sum()
+
+        if anon_set == "random": 
+
+            col_values = col_df[col_name].to_list()
+
+            original_series = pd.Series(
+                data=self.rng.choice(a=col_values, size=self.num_rows, p=col_prob),
+                name=col_name)
+
+            if paired_cols:
+                paired_df = (
+                    col_df[[col_name] + [f"paired_{x}" for x in paired_cols]]
+                        .rename(columns=lambda x: x.replace("paired_", ""))
+                )
+
+                return shuffle_data(
+                    pd.merge(original_series, paired_df, how="left", on=col_name))
+
+            return shuffle_data(original_series)
+
+        # finally, if we have original_values, but anon_set is not random
+        # we pick the N distinct values from the anonymysing set, replace
+        # the original values + paired column values in the original_values
+        # DATAFRAME, making sure the changes happen in-place which means
+        # that downstream, the weights table will be built based on the
+        # modified "original_values" dataframe.
+
+        sql_df = self._generate_from_sql(col_name, col_attrs, complete=True)
+
+        # includes Missing data row as opposed to col_df which doesn't
+        orig_df = col_attrs["original_values"]
+
+        # missing data is the last row
+        repl = sql_df[col_name].unique()
+        aliases = dict(zip(orig_df[col_name].values[:-1], repl))
+        aliased_df = orig_df.map(lambda x: aliases.get(x, x))
+        self.spec_dict["columns"][col_name]["original_values"] = aliased_df
+
+        # we ignore Missing data probability when we originally create the variable
+        idx = self.rng.choice(a=len(sql_df), p=col_prob, size=self.num_rows)
+        anon_list = [sql_df.iloc[x, :].values for x in idx]
+        anon_df = pd.DataFrame(columns=sql_df.columns, data=anon_list)
+
+        return shuffle_data(anon_df)
+        
+    def _generate_from_sql(self, col_name, col_attrs, complete=False, db_path=None):
+        '''
+        Whatever the anonymising method, if a column has more unique values than
+        allowed by the inline_limit parameter, it will be put into SQLite3 db.
+        '''
+
+        anon_set = col_attrs["anonymising_set"]
+        uniques = col_attrs["uniques"]
+        paired_cols = col_attrs["paired_columns"] or []
+
+        #1) QUERY SQL TO GET VALUES USED TO BUILD THE DATAFRAME
+        if anon_set == "random":
+
+            safe_col_name = col_name.replace(" ", "$")
+            table_name = f"temp_{self.spec_dict['metadata']['id']}_{safe_col_name}"
+            sql_df = query_exhibit_database(
+                table_name, exclude_missing=True, db_path=db_path)
+
+        else:
+            table_name, *sql_column = anon_set.split(".")
+            sql_df = query_exhibit_database(table_name, sql_column, uniques)
+
+        # if sql df is an anonymising set with different column names, like mountaints,
+        # we want to rename them to the actual column names used in the spec;
+        # alternatively, if the sql df is a lookup and column there match the spec, we
+        # make sure to take those columns that match.
+        if set([col_name] + paired_cols).issubset(set(sql_df.columns)):
+            sql_df = sql_df[[col_name] + paired_cols]
+
+        # rename sql_df columns to be same as original + paired; zip is 
+        # only going to pair up columns up to the shorter list!
+        sql_df.rename(
+            columns=dict(zip(
+                sql_df.columns,
+                [col_name] + paired_cols
+            )),
+            inplace=True
+        )
+
+        #2) GENERATE ANONYMISED ROWS
+        if complete:
+            anon_df = sql_df.drop(columns="probability_vector", errors="ignore")
+        else:
+            if "probability_vector" in sql_df.columns:
+                probs = sql_df["probability_vector"].astype(float).values
+                probs = probs / probs.sum()
+                sql_df.drop(columns="probability_vector", inplace=True)
+                idx = self.rng.choice(a=len(sql_df), p=probs, size=self.num_rows)
+            else:
+                idx = self.rng.choice(len(sql_df), self.num_rows)
+
+            anon_list = [sql_df.iloc[x, :].values for x in idx]
+            anon_df = pd.DataFrame(columns=sql_df.columns, data=anon_list)
+
+        #3) HANDLE MISSING PAIRED COLUMNS IN SQL
+        # if the column has paired columns and a non-random anonymising set,
+        # the anonymising set must also provide the paired columns or the same
+        # values will be used for the original + paired columns
+        missing_paired_cols = set(paired_cols) - set(sql_df.columns[1:])
+
+        if missing_paired_cols:
+            missing_df = pd.DataFrame(
+                data=zip(*[anon_df[col_name]] * len(missing_paired_cols)),
+                # sets are no longer allowed as column names
+                columns=list(missing_paired_cols)
+            )
+
+            anon_df = pd.concat([anon_df, missing_df], axis=1)
+
+        return shuffle_data(anon_df)
+
+    def _generate_complete_series(self, col_name):
+        '''
+        This function doesn't take num_rows argument because
+        we are always generating the full number of rows
+        for this column as specified in the spec.
+
+        Function path depends on the column type: date or categorical
+
+        Returns
+        -------
+        pd.Series for non-paired columns and pd.DataFrame for pairs
+
+        For now, the function doesn't support columns where values are
+        stored in the DB because the number of their uniques exceeds
+        category threshold or if they are anonymised using a set from DB.
+        '''
+        
+        col_attrs = self.spec_dict["columns"][col_name]
+        
+        if col_attrs["type"] == "date":
+
+            return self._generate_timeseries(col_name, complete=True) 
+        
+        # if paired column, skip, and add pairs as part of parent column's processing
+        if col_name in self.paired_cols:
+            return None
+
+        # if column has paired columns, return a dataframe with it + paired cols
+        paired_cols = col_attrs["paired_columns"]
+
+        # all cat. columns have a missing data placeholder as -1 row so we exclude it
+        if paired_cols:
+            paired_complete_df = (
+                col_attrs["original_values"].iloc[:-1, 0:len(paired_cols)+1])
+            paired_complete_df.rename(
+                columns=lambda x: x.replace("paired_", ""), inplace=True)
+
+            return paired_complete_df
+
+        return pd.Series(col_attrs["original_values"].iloc[:-1, 0], name=col_name)
+
+    def _get_column_types(self):
+        '''
+        Convenience function to categorise columns into 4 types:
+            - nested linked columns (generated separately as part of linkage.py)
+            - complete columns - all values are used
+            - columns where original values are paired with a "main" column
+
+        All of the above are treated in a special way either in a separate
+        generation routine (like linked columns) or are generated as a
+        by-product of another routine (like paired columns). Columns that remain,
+        are generated in a "normal" way as part of this module.
+
+        Returns
+        -------
+        namedtuple("Columns", ["all", "complete", "paired", "skipped"])
+        '''
+
+        Columns = namedtuple("Columns", ["all", "complete", "paired", "skipped"])
+
+        # there might be cases when you want to generate just the date columns or just
+        # the categorical columns so they might be missing from the metadata section
+        all_cols = (
+            (self.spec_dict["metadata"].get("categorical_columns", [])) +
+            (self.spec_dict["metadata"].get("date_columns", []))
+        )
+        
+        nested_linked_cols = [
+            sublist for n, sublist in (self.spec_dict.get("linked_columns") or [])
+            ]
+
+        complete_cols = [c for c, v in get_attr_values(
+            self.spec_dict,
+            "cross_join_all_unique_values",
+            col_names=True, 
+            types=["categorical", "date"]) if v]
+
+        list_of_orig_val_tuples = get_attr_values(
+            self.spec_dict,
+            "original_values",
+            col_names=True,
+            types=["categorical", "date"])
+
+        paired_cols = [
+            k for k, v in list_of_orig_val_tuples if str(v) == ORIGINAL_VALUES_PAIRED]
+
+        skipped_cols = (
+            list(chain.from_iterable(nested_linked_cols)) +
+            complete_cols +
+            paired_cols
+        )
+
+        column_types = Columns(all_cols, complete_cols, paired_cols, skipped_cols)
+
+        return column_types
+
+    def _generate_using_external_table(self, col_name, anon_set):
+        '''
+        We assume that the aliased column is the one you want to pick the values from
+        and the rest of the columns in the select statement are going to be the join
+        keys.
+        '''
+
+        parser = Parser(anon_set)
+        sql_tables = parser.tables
+        aliased_columns = parser.columns_aliases_names
+        source_table_id = self.spec_dict["metadata"]["id"]
+    
+        if len(aliased_columns) != 1 or aliased_columns[0] != col_name:
+            raise RuntimeError(
+                f"Please make sure the SQL SELECT statement in {col_name}'s "
+                f"anonymising_set includes exactly one aliased column named {col_name}."
+            )
+        
+        # "join" columns are all non-aliased columns from the source table
+        # "join" here refers to joining back the data from the SQL statment to the
+        # original source data, not any join columns that are part of the JOIN section
+        # of SQL proper.
+
+        join_columns = []
+        for qualified_column in parser.columns_dict["select"]:
+            table, column = qualified_column.split(".")
+            if table == f"temp_{source_table_id}" and column != col_name:
+                join_columns.append(column)
+
+        # "source" table aka existing table is always put into exhibit DB, but if 
+        # SQL is trying to reference an external table, we should check if it exists
+        ext_tables = [
+            t for t in sql_tables if t not in ["temp_original_values", f"temp_{source_table_id}"]
+        ]
+
+        # check the "external" table is in exhibit.db
+        for ext_table in ext_tables:
+            if not check_table_exists(ext_table):
+                raise RuntimeError(
+                    f"Please make sure that {ext_table} used in the anonymising_set SQL"
+                    f" for column {col_name} exists in the Exhibit database."
+                )
+        
+        # insert the dataframe generated so far into the DB; we make sure to drop
+        # duplicates in case user didn't specify DISTINCT in his SQL query;
+        # the anon_df would typically be from UUIDs that are generated before
+        # categorical columns.
+
+        # self.anon_df is what is generated BEFORE categorical columns, e.g UUID columns
+        if self.anon_df is None or self.anon_df.empty:
+            # self.generated_dfs has cat. columns generated BEFORE this particular column
+            if not self.generated_dfs: #pragma: no cover
+                existing_data = pd.DataFrame()
+            else:
+                existing_data = pd.concat(self.generated_dfs, axis=1)
+        else:
+            existing_data = pd.concat(self.generated_dfs + [self.anon_df], axis=1)
+
+        # for convenience, we can reference original_values as a table - this could be 
+        # original_values as they appear in the SPEC or in the SQL (not implemented yet)
+        if "temp_original_values" in sql_tables:
+            ov_df = self.spec_dict["columns"][col_name]["original_values"][[col_name]]
+            create_temp_table(
+                table_name="temp_original_values",
+                col_names=[col_name],
+                data=ov_df
+            )
+
+        # ensure the data going into DB is processed identically for join keys
+        for col in join_columns:
+            if is_numeric_dtype(existing_data[col]):
+                existing_data[col] = existing_data[col].astype(float)
+            elif is_datetime64_dtype(existing_data[col]):
+                existing_data[col] = existing_data[col].dt.strftime("%Y-%m-%d")
+            else:
+                existing_data[col] = existing_data[col].astype(str).str.strip()
+
+        # dropping duplicates is a filter operation (even though it returns new data)
+        # unless we make an explicit copy of the de-duplicated dataframe, Pandas will 
+        # trigger SettingWithCopy warning when trying to change any values.
+        existing_data_distinct = existing_data.drop_duplicates(subset=join_columns).copy()
+        existing_data_cols = list(existing_data.columns)
+
+        # this function converts list of tuples into a dataframe anyway
+        create_temp_table(
+            table_name=f"temp_{source_table_id}",
+            col_names=existing_data_cols,
+            data=existing_data_distinct
+        )
+
+        # run the SQL from anon_set; note that the type of SQL query we'll likely see 
+        # will be a cross-join (e.g. dates) so any speed optimisations would be welcome
+        result = execute_sql(anon_set)
+
+        # create the dataframe with SQL data
+        sql_df = pd.DataFrame(data=result, columns=join_columns + aliased_columns)
+
+        # ensure that the column of interest (the one we're potentially matching to original
+        # values) is typed to string - and not datetime or int, coming out of SQL. We will
+        # convert to datetime at the end, if that's what the type in the spec is.
+        sql_df[col_name] = sql_df[col_name].astype("str")
+
+        # get the probabilities for the selected column in the external table
+        # at the level of the join key - use a hash for the combination of columns!
+
+        # Rather than use existing probabilities from the spec, treat them as a weight 
+        # and apply them to the conditional, per-join key probabilities from external
+        # table.
+        probas = {}
+        orig_vals = None
+
+        try:
+            orig_vals = self.spec_dict["columns"][col_name]["original_values"]
+            if isinstance(orig_vals, pd.DataFrame):
+                orig_vals = orig_vals.set_index(col_name)
+        # if we don't have original_values in the column spec, it's a date
+        except KeyError:
+            pass
+
+        groups = sql_df.groupby(join_columns)
+        for i, group in groups:
+
+            total_count = len(group)
+            proba_arr = (group
+                            .value_counts()
+                            .apply(lambda x: 0 if x == 0 else max(0.001, x / total_count))
+                            .reset_index(level=col_name)
+                            .to_numpy(dtype="str")
+                            )
+            a, p = np.split(proba_arr, 2, axis=1)
+            a = a.flatten()
+            p = p.flatten().astype(float)
+
+            if orig_vals is not None:
+                for j, val in enumerate(a):
+                    if val in orig_vals.index:
+                        p_weight = float(orig_vals.loc[val, "probability_vector"])
+                        p[j] = p[j] * p_weight
+
+            # enusre p sums up to 1
+            p = p * (1 / sum(p))
+            probas[i[0]] = (a, p)
+
+        # take the data generated so far and generate appropriate values based on key
+        groups = existing_data.groupby(join_columns).groups
+        temp_result = []
+
+        for group_key, group_index in groups.items():
+            # if the key is missing, then the SQL filtered out the data for that key
+            # having a COALESCE in SQL would fix it, but in case it's also missing, 
+            # we try to catch this edge case in code as well. 
+            try:
+                new_data = self.rng.choice(
+                    a=probas[group_key][0], p=probas[group_key][1], size=len(group_index))
+            except KeyError: #pragma: no cover
+                new_data = [np.nan] * len(group_index)
+    
+            temp_result.append(pd.Series(data=new_data, index=group_index, name=col_name))
+
+        final_result = pd.concat(temp_result)
+
+        # ensure we return the correct type for date columns
+        col_type = self.spec_dict["columns"][col_name]["type"]
+        if col_type == "date":
+            final_result = final_result.astype("datetime64[ns]")
+
+        return final_result
+    
+    def _generate_using_custom_function(self, col_name, anon_set):
+        '''
+        _summary_
+
+        Parameters
+        ----------
+        col_name : _type_
+            _description_
+        anon_set : _type_
+            _description_
+        '''
+        # self.anon_df is what is generated BEFORE categorical columns, e.g UUID columns
+        if self.anon_df is None or self.anon_df.empty:
+            # self.generated_dfs has cat. columns generated BEFORE this particular column
+            if not self.generated_dfs:
+                existing_data = pd.DataFrame()
+            else:
+                existing_data = pd.concat(self.generated_dfs, axis=1)
+        else: #pragma: no cover
+            existing_data = pd.concat(self.generated_dfs + [self.anon_df], axis=1)
+
+        if existing_data.empty:
+            result = pd.Series(
+                data=[anon_set(pd.Series) for _ in range(self.num_rows)],
+                name=col_name
+            )
+            return result
+
+        result = existing_data.apply(anon_set, axis=1)
+        result.name = col_name
+
+        return result
diff --git a/exhibit/core/generate/missing.py b/exhibit/core/generate/missing.py
index 76eb76c..01388e5 100644
--- a/exhibit/core/generate/missing.py
+++ b/exhibit/core/generate/missing.py
@@ -1,345 +1,345 @@
-'''
-Methods to generate / deal with missing data
-'''
-
-# Standard library imports
-from itertools import groupby
-
-# External library imports
-import numpy as np
-import pandas as pd
-
-# Exhibit 
-from ..constants import MISSING_DATA_STR
-from ..constraints import clean_up_constraint_string, get_constraint_mask
-from ..utils import get_attr_values
-from .continuous import generate_cont_val, scale_continuous_column
-
-# EXPORTABLE METHODS & CLASSES
-# ============================
-
-class MissingDataGenerator:
-    '''
-    The class will copy the nearly complete anonimised dataframe
-    which has implications on the RAM footprint of the package
-    '''
-
-    def __init__(self, spec_dict, data):
-        '''
-        Doc string
-        '''
-
-        self.spec_dict = spec_dict
-        self.data = data
-        self.dtypes = data.dtypes
-        self.nan_data = data
-        self.wt = spec_dict.get("weights_table", None)
-
-        # only copy the data if there are conditional constraints meaning
-        # we can't be sure the required columns HADN'T HAD data already made
-        # missing in an earlier step.
-        if spec_dict["constraints"]["custom_constraints"]:
-            self.nan_data = data.copy()
-
-    def add_missing_data(self):
-        '''
-        Returns the original data, modified in place to include nan values
-
-        Since Missing data (categorical) has its own weights, if we're adding
-        any Missing data to the dataframe, we must re-generate the contunious
-        variables to make sure we use the Missing data weights and not the original.
-
-        We also need to re-scale each continuous column where we either added a nan
-        or where the categorical columns had Missing data added to them.
-
-        1) Find cells to exclude - there can't be nans in them
-        2) Find linked and paired columns - nulls are propagated from the root column
-        3) Add nulls to the remaining columns, always mindful of the indices from 1)
-        '''
-
-        missing_link_cols = self._find_columns_with_linked_missing_data()
-        geospatial_cols = [c for c, _ in get_attr_values(
-        self.spec_dict, "type", col_names=True, types=["geospatial"])]
-
-        standalone_cols = (
-            set(self.spec_dict["columns"].keys()) - 
-            {col for col_set in missing_link_cols for col in col_set} -
-            set(self.spec_dict.get("derived_columns", {}).keys()) -
-            set(self.spec_dict["metadata"].get("uuid_columns", set()) or set()) -
-            set(geospatial_cols)
-        )
-
-        #1) Generate nulls in standalone columns, including continuous
-        # make sure to change the seed for each standalone column to avoid creating
-        # relationships where NA in Column A is also NA is column B if both have the
-        # same miss_probability.
-        for i, col_name in enumerate(sorted(standalone_cols)):
-
-            # reset the generator for each column
-            rng = np.random.default_rng(seed=i)
-
-            # special case for user linked columns which can have "Missing data" already
-            # if it appeared in the source for the linkage along with its probability.
-            # hierarchical linkage is not affected because having multiple NAN CAs for 
-            # different HBs, for example, means the linkage is no longer hierarchical
-            # and doesn't map 1 to many and is instead many to many.
-            
-            if any(self.nan_data[col_name] == MISSING_DATA_STR):
-                self.nan_data[col_name] = (
-                        self.nan_data[col_name].map(
-                            lambda x: pd.NA if x == MISSING_DATA_STR else x))
-                continue
-            
-            miss_pct = self.spec_dict["columns"][col_name]["miss_probability"]
-            rands = rng.random(size=self.nan_data.shape[0]) # pylint: disable=no-member
-            col_type = self.spec_dict["columns"][col_name]["type"]
-            miss_value = pd.NaT if col_type == "date" else np.NaN
-            repl_column = self.nan_data[col_name]
-            
-            # numpy default type detection messes up date columns in Pandas
-            if col_type == "date":
-                repl_column = np.array(self.nan_data[col_name], dtype=object)
-
-            self.nan_data[col_name] = np.where(
-                rands < miss_pct,
-                miss_value, repl_column)
-
-            if col_type == "continuous":
-                precision = self.spec_dict["columns"][col_name].get("precision", None)
-                if precision == "integer":
-                    self.nan_data[col_name] = (
-                        self.nan_data[col_name].astype("float").round().astype("Int64"))
-
-        #2) Generate nulls in linked and paired columns
-        for cols in missing_link_cols:
-            
-            # reset the generator for each column (keeping the seed to maintain links)
-            rng = np.random.default_rng(seed=0)
-         
-            # miss probability will be the same for all columns in cols
-            miss_pct = self.spec_dict["columns"][next(iter(cols))]["miss_probability"]
-            # rands is shared for all columns in cols
-            rands = rng.random(size=self.nan_data.shape[0]) # pylint: disable=no-member
-
-            self.nan_data.loc[:, list(cols)] = np.where(
-                (rands < miss_pct)[..., None],
-                (np.NaN, ) * len(cols),
-                self.nan_data.loc[:, list(cols)]
-            )
-
-        #3) Generate nulls in geospacial columns (lat / long)
-        # Similar to linked / paired, keeping the random seed the same between
-        # lat and long, only changing if more than one column to generate.
-
-        for col in geospatial_cols:
-            geo_cols = [f"{col}_latitude", f"{col}_longitude"]
-            rng = np.random.default_rng(seed=0)
-            miss_pct = self.spec_dict["columns"][col]["miss_probability"]
-            rands = rng.random(size=self.nan_data.shape[0])
-
-            self.nan_data.loc[:, geo_cols] = np.where(
-                (rands < miss_pct)[..., None],
-                (np.NaN, ) * len(geo_cols),
-                self.nan_data.loc[:, geo_cols]
-            )
-            
-        #4) Generate nulls in indices explicitly defined in custom_constraints
-        make_null_idx = self._find_make_null_idx()
-
-        for idx, col_name in make_null_idx:
-            self.nan_data.loc[idx, col_name] = np.NaN
-
-        #5) Re-introduce the saved no_nulls rows from the original data
-        not_null_idx = self._find_not_null_idx()
-        for idx, col_name in not_null_idx:
-            self.nan_data.loc[idx, col_name] = self.data.loc[idx, col_name]
-
-        #6) Replace NA with missing data placeholder for categorical columns and
-        # re-generate continuous variables for those rows according to proper weights
-        # only go through this step if there are nulls in categorical columns
-        # and the spec_dict includes numerical columns that would be affected
-        # otherwise, return early.
-        cat_cols = list(self.spec_dict["metadata"]["categorical_columns"])
-        num_cols = (
-            set(self.spec_dict["metadata"]["numerical_columns"]) -
-            set(self.spec_dict.get("derived_columns", {}).keys()))
-
-        if not (any(self.nan_data[cat_cols].isna()) and num_cols):
-            return self.nan_data.astype(self.dtypes)
-
-        cat_mask = self.nan_data[cat_cols].isna().any(axis=1)
-        self.nan_data[cat_cols] = self.nan_data[cat_cols].fillna(MISSING_DATA_STR)
-        
-        for num_col in num_cols:
-
-            # reset the generator for each column
-            rng = np.random.default_rng(seed=0)
-           
-            # Extract relevant num col variables from the user spec
-            num_col_dict = self.spec_dict["columns"][num_col]
-
-            dist = num_col_dict["distribution"]
-            dist_params = num_col_dict["distribution_parameters"]
-            precision = num_col_dict["precision"]
-            
-            # if it's already NA, don't re-generate; it's NA for a reason!
-            num_mask = self.nan_data[num_col].isna()
-            mask = cat_mask & ~num_mask
-
-            # it's possible to have the left side be Int64 type and the right side
-            # to be float64 (newly generated, unscaled); assigning different types
-            # doesn't work so we'll delay assignment and scale / cast type first!
-            unscaled_new_series = self.nan_data.loc[mask, cat_cols].apply(
-                func=generate_cont_val,
-                axis=1,
-                weights_table=self.wt,
-                num_col=num_col,
-                rng=rng,
-                dist=dist,
-                dist_params=dist_params
-            )
-
-            # rescale the masked section, but make sure to change target_sum!
-            # take a copy of the dist_params as full target_sum is used elsewhere
-            new_dist_params = dist_params.copy()
-
-            if dist_params.get("target_sum", None) is not None:
-                old_sum = self.nan_data.loc[~mask, num_col].sum()
-                new_dist_params["target_sum"] = dist_params["target_sum"] - old_sum
-
-            scaled_new_series = scale_continuous_column(
-                series=unscaled_new_series,
-                precision=precision,
-                **new_dist_params
-            )
-
-            # for some reason assigning a series back, rather than values
-            # creates nulls in certain rows, but not others; maybe Pandas bug.
-            # when the array is empty, Pandas generates a ValueError
-            if len(scaled_new_series) != 0:
-                self.nan_data.loc[mask, num_col] = scaled_new_series.values
-
-        # replace Missing data back with np.nan
-        # since we're applying the function across all columns, including numerical,
-        # these can contain pd.NA which is a "special" type that will error out if
-        # trying to evaluate it against a string. Replace with a standard np.NAN.
-        self.nan_data = self.nan_data.applymap(
-            lambda x: np.nan if pd.isna(x) or x == MISSING_DATA_STR else x)
-
-        return self.nan_data.astype(self.dtypes)
-
-    def _find_columns_with_linked_missing_data(self):
-        '''
-        Returns a list of column groupings where a missing value in one
-        means always a missing value in all in the grouping. The requirement
-        for that is that the missing_probability attribute of the spec is the
-        same for all such linked / paired columns.
-
-        Returns a list with sets of columns
-        '''
-        
-        result = []
-        processed_pairs = set()
-        miss_probs = get_attr_values(
-            self.spec_dict, "miss_probability", col_names=True, types="categorical")
-
-        for col, attrs in self.spec_dict["columns"].items():
-
-            if col in processed_pairs or attrs["type"] != "categorical":
-                continue
-
-            pairs = set()
-            
-            # paired columns first
-            if attrs["paired_columns"]:
-
-                pairs.update([col] + attrs["paired_columns"])
-
-            # linked groups
-            for i, linked_group in (self.spec_dict["linked_columns"] or []):
-                # zero numbered linked group is reserved for user defined linkage
-                if i == 0:
-                    continue
-
-                if col in linked_group:
-                    pairs.update(linked_group)
-
-            processed_pairs.update(pairs)
-
-            # check that miss_probabilities are the same for all paired columns
-            miss_probs = sorted(
-                miss_probs, key=lambda x, pairs=pairs: x.col_name in pairs)
-            groups = groupby(miss_probs, lambda x, pairs=pairs: x.col_name in pairs)
-
-            for key, group in groups:
-
-                if key and len({v for k, v in group}) == 1:
-
-                    result.append(pairs)
-
-        return result
-
-
-    def _find_make_null_idx(self):
-        '''
-        The reason for keeping this and _find_not_null_idx separate is that
-        they are needed at different points in time - not_null_idx happens AFTER
-        all other sources of nan-generation have been exhausted and we're using
-        the data WITH nans to determine indices to pick up real values from the
-        original data passed in to the generator.
-        '''
-        
-        cc = self.spec_dict["constraints"]["custom_constraints"] or {}
-
-        make_null_idx = []
-        
-        for _, constraint in cc.items():
-
-            cc_filter = constraint.get("filter", None)
-            cc_targets = constraint.get("targets", {})
-            clean_cc_filter = clean_up_constraint_string(cc_filter)
-            cc_mask = get_constraint_mask(self.nan_data, clean_cc_filter)
-
-            for target_str, action_str in cc_targets.items():
-
-                if "make_null" in action_str:
-
-                    target_cols = [x.strip() for x in target_str.split(",")]
-
-                    for target in target_cols:
-
-                        make_null_idx.append(
-                            (self.nan_data.loc[cc_mask].index, target)
-                        )
-
-        return make_null_idx
-
-    def _find_not_null_idx(self):
-        '''
-        Doc string
-        '''
-        
-        cc = self.spec_dict["constraints"]["custom_constraints"] or {}
-
-        not_null_idx = []
-            
-        for _, constraint in cc.items():
-
-            cc_filter = constraint.get("filter", None)
-            cc_targets = constraint.get("targets", {})
-            clean_cc_filter = clean_up_constraint_string(cc_filter)
-            cc_mask = get_constraint_mask(self.nan_data, clean_cc_filter)
-
-            for target_str, action_str in cc_targets.items():
-
-                if "make_not_null" in action_str:
-
-                    target_cols = [x.strip() for x in target_str.split(",")]
-
-                    for target in target_cols:
-
-                        not_null_idx.append(
-                            (self.nan_data.loc[cc_mask].index, target)
-                        )
-
-        return not_null_idx
+'''
+Methods to generate / deal with missing data
+'''
+
+# Standard library imports
+from itertools import groupby
+
+# External library imports
+import numpy as np
+import pandas as pd
+
+# Exhibit 
+from ..constants import MISSING_DATA_STR
+from ..constraints import clean_up_constraint_string, get_constraint_mask
+from ..utils import get_attr_values
+from .continuous import generate_cont_val, scale_continuous_column
+
+# EXPORTABLE METHODS & CLASSES
+# ============================
+
+class MissingDataGenerator:
+    '''
+    The class will copy the nearly complete anonimised dataframe
+    which has implications on the RAM footprint of the package
+    '''
+
+    def __init__(self, spec_dict, data):
+        '''
+        Doc string
+        '''
+
+        self.spec_dict = spec_dict
+        self.data = data
+        self.dtypes = data.dtypes
+        self.nan_data = data
+        self.wt = spec_dict.get("weights_table", None)
+
+        # only copy the data if there are conditional constraints meaning
+        # we can't be sure the required columns HADN'T HAD data already made
+        # missing in an earlier step.
+        if spec_dict["constraints"]["custom_constraints"]:
+            self.nan_data = data.copy()
+
+    def add_missing_data(self):
+        '''
+        Returns the original data, modified in place to include nan values
+
+        Since Missing data (categorical) has its own weights, if we're adding
+        any Missing data to the dataframe, we must re-generate the contunious
+        variables to make sure we use the Missing data weights and not the original.
+
+        We also need to re-scale each continuous column where we either added a nan
+        or where the categorical columns had Missing data added to them.
+
+        1) Find cells to exclude - there can't be nans in them
+        2) Find linked and paired columns - nulls are propagated from the root column
+        3) Add nulls to the remaining columns, always mindful of the indices from 1)
+        '''
+
+        missing_link_cols = self._find_columns_with_linked_missing_data()
+        geospatial_cols = [c for c, _ in get_attr_values(
+        self.spec_dict, "type", col_names=True, types=["geospatial"])]
+
+        standalone_cols = (
+            set(self.spec_dict["columns"].keys()) - 
+            {col for col_set in missing_link_cols for col in col_set} -
+            set(self.spec_dict.get("derived_columns", {}).keys()) -
+            set(self.spec_dict["metadata"].get("uuid_columns", set()) or set()) -
+            set(geospatial_cols)
+        )
+
+        #1) Generate nulls in standalone columns, including continuous
+        # make sure to change the seed for each standalone column to avoid creating
+        # relationships where NA in Column A is also NA is column B if both have the
+        # same miss_probability.
+        for i, col_name in enumerate(sorted(standalone_cols)):
+
+            # reset the generator for each column
+            rng = np.random.default_rng(seed=i)
+
+            # special case for user linked columns which can have "Missing data" already
+            # if it appeared in the source for the linkage along with its probability.
+            # hierarchical linkage is not affected because having multiple NAN CAs for 
+            # different HBs, for example, means the linkage is no longer hierarchical
+            # and doesn't map 1 to many and is instead many to many.
+            
+            if any(self.nan_data[col_name] == MISSING_DATA_STR):
+                self.nan_data[col_name] = (
+                        self.nan_data[col_name].map(
+                            lambda x: pd.NA if x == MISSING_DATA_STR else x))
+                continue
+            
+            miss_pct = self.spec_dict["columns"][col_name]["miss_probability"]
+            rands = rng.random(size=self.nan_data.shape[0]) # pylint: disable=no-member
+            col_type = self.spec_dict["columns"][col_name]["type"]
+            miss_value = pd.NaT if col_type == "date" else np.nan
+            repl_column = self.nan_data[col_name]
+            
+            # numpy default type detection messes up date columns in Pandas
+            if col_type == "date":
+                repl_column = np.array(self.nan_data[col_name], dtype=object)
+
+            self.nan_data[col_name] = np.where(
+                rands < miss_pct,
+                miss_value, repl_column)
+
+            if col_type == "continuous":
+                precision = self.spec_dict["columns"][col_name].get("precision", None)
+                if precision == "integer":
+                    self.nan_data[col_name] = (
+                        self.nan_data[col_name].astype("float").round().astype("Int64"))
+
+        #2) Generate nulls in linked and paired columns
+        for cols in missing_link_cols:
+            
+            # reset the generator for each column (keeping the seed to maintain links)
+            rng = np.random.default_rng(seed=0)
+         
+            # miss probability will be the same for all columns in cols
+            miss_pct = self.spec_dict["columns"][next(iter(cols))]["miss_probability"]
+            # rands is shared for all columns in cols
+            rands = rng.random(size=self.nan_data.shape[0]) # pylint: disable=no-member
+
+            self.nan_data.loc[:, list(cols)] = np.where(
+                (rands < miss_pct)[..., None],
+                (np.nan, ) * len(cols),
+                self.nan_data.loc[:, list(cols)]
+            )
+
+        #3) Generate nulls in geospacial columns (lat / long)
+        # Similar to linked / paired, keeping the random seed the same between
+        # lat and long, only changing if more than one column to generate.
+
+        for col in geospatial_cols:
+            geo_cols = [f"{col}_latitude", f"{col}_longitude"]
+            rng = np.random.default_rng(seed=0)
+            miss_pct = self.spec_dict["columns"][col]["miss_probability"]
+            rands = rng.random(size=self.nan_data.shape[0])
+
+            self.nan_data.loc[:, geo_cols] = np.where(
+                (rands < miss_pct)[..., None],
+                (np.nan, ) * len(geo_cols),
+                self.nan_data.loc[:, geo_cols]
+            )
+            
+        #4) Generate nulls in indices explicitly defined in custom_constraints
+        make_null_idx = self._find_make_null_idx()
+
+        for idx, col_name in make_null_idx:
+            self.nan_data.loc[idx, col_name] = np.nan
+
+        #5) Re-introduce the saved no_nulls rows from the original data
+        not_null_idx = self._find_not_null_idx()
+        for idx, col_name in not_null_idx:
+            self.nan_data.loc[idx, col_name] = self.data.loc[idx, col_name]
+
+        #6) Replace NA with missing data placeholder for categorical columns and
+        # re-generate continuous variables for those rows according to proper weights
+        # only go through this step if there are nulls in categorical columns
+        # and the spec_dict includes numerical columns that would be affected
+        # otherwise, return early.
+        cat_cols = list(self.spec_dict["metadata"]["categorical_columns"])
+        num_cols = (
+            set(self.spec_dict["metadata"]["numerical_columns"]) -
+            set(self.spec_dict.get("derived_columns", {}).keys()))
+
+        if not (any(self.nan_data[cat_cols].isna()) and num_cols):
+            return self.nan_data.astype(self.dtypes)
+
+        cat_mask = self.nan_data[cat_cols].isna().any(axis=1)
+        self.nan_data[cat_cols] = self.nan_data[cat_cols].fillna(MISSING_DATA_STR)
+        
+        for num_col in num_cols:
+
+            # reset the generator for each column
+            rng = np.random.default_rng(seed=0)
+           
+            # Extract relevant num col variables from the user spec
+            num_col_dict = self.spec_dict["columns"][num_col]
+
+            dist = num_col_dict["distribution"]
+            dist_params = num_col_dict["distribution_parameters"]
+            precision = num_col_dict["precision"]
+            
+            # if it's already NA, don't re-generate; it's NA for a reason!
+            num_mask = self.nan_data[num_col].isna()
+            mask = cat_mask & ~num_mask
+
+            # it's possible to have the left side be Int64 type and the right side
+            # to be float64 (newly generated, unscaled); assigning different types
+            # doesn't work so we'll delay assignment and scale / cast type first!
+            unscaled_new_series = self.nan_data.loc[mask, cat_cols].apply(
+                func=generate_cont_val,
+                axis=1,
+                weights_table=self.wt,
+                num_col=num_col,
+                rng=rng,
+                dist=dist,
+                dist_params=dist_params
+            )
+
+            # rescale the masked section, but make sure to change target_sum!
+            # take a copy of the dist_params as full target_sum is used elsewhere
+            new_dist_params = dist_params.copy()
+
+            if dist_params.get("target_sum", None) is not None:
+                old_sum = self.nan_data.loc[~mask, num_col].sum()
+                new_dist_params["target_sum"] = dist_params["target_sum"] - old_sum
+
+            scaled_new_series = scale_continuous_column(
+                series=unscaled_new_series,
+                precision=precision,
+                **new_dist_params
+            )
+
+            # for some reason assigning a series back, rather than values
+            # creates nulls in certain rows, but not others; maybe Pandas bug.
+            # when the array is empty, Pandas generates a ValueError
+            if len(scaled_new_series) != 0:
+                self.nan_data.loc[mask, num_col] = scaled_new_series.values
+
+        # replace Missing data back with np.nan
+        # since we're applying the function across all columns, including numerical,
+        # these can contain pd.NA which is a "special" type that will error out if
+        # trying to evaluate it against a string. Replace with a standard np.NAN.
+        self.nan_data = self.nan_data.map(
+            lambda x: np.nan if pd.isna(x) or x == MISSING_DATA_STR else x)
+
+        return self.nan_data.astype(self.dtypes)
+
+    def _find_columns_with_linked_missing_data(self):
+        '''
+        Returns a list of column groupings where a missing value in one
+        means always a missing value in all in the grouping. The requirement
+        for that is that the missing_probability attribute of the spec is the
+        same for all such linked / paired columns.
+
+        Returns a list with sets of columns
+        '''
+        
+        result = []
+        processed_pairs = set()
+        miss_probs = get_attr_values(
+            self.spec_dict, "miss_probability", col_names=True, types="categorical")
+
+        for col, attrs in self.spec_dict["columns"].items():
+
+            if col in processed_pairs or attrs["type"] != "categorical":
+                continue
+
+            pairs = set()
+            
+            # paired columns first
+            if attrs["paired_columns"]:
+
+                pairs.update([col] + attrs["paired_columns"])
+
+            # linked groups
+            for i, linked_group in (self.spec_dict["linked_columns"] or []):
+                # zero numbered linked group is reserved for user defined linkage
+                if i == 0:
+                    continue
+
+                if col in linked_group:
+                    pairs.update(linked_group)
+
+            processed_pairs.update(pairs)
+
+            # check that miss_probabilities are the same for all paired columns
+            miss_probs = sorted(
+                miss_probs, key=lambda x, pairs=pairs: x.col_name in pairs)
+            groups = groupby(miss_probs, lambda x, pairs=pairs: x.col_name in pairs)
+
+            for key, group in groups:
+
+                if key and len({v for k, v in group}) == 1:
+
+                    result.append(pairs)
+
+        return result
+
+
+    def _find_make_null_idx(self):
+        '''
+        The reason for keeping this and _find_not_null_idx separate is that
+        they are needed at different points in time - not_null_idx happens AFTER
+        all other sources of nan-generation have been exhausted and we're using
+        the data WITH nans to determine indices to pick up real values from the
+        original data passed in to the generator.
+        '''
+        
+        cc = self.spec_dict["constraints"]["custom_constraints"] or {}
+
+        make_null_idx = []
+        
+        for _, constraint in cc.items():
+
+            cc_filter = constraint.get("filter", None)
+            cc_targets = constraint.get("targets", {})
+            clean_cc_filter = clean_up_constraint_string(cc_filter)
+            cc_mask = get_constraint_mask(self.nan_data, clean_cc_filter)
+
+            for target_str, action_str in cc_targets.items():
+
+                if "make_null" in action_str:
+
+                    target_cols = [x.strip() for x in target_str.split(",")]
+
+                    for target in target_cols:
+
+                        make_null_idx.append(
+                            (self.nan_data.loc[cc_mask].index, target)
+                        )
+
+        return make_null_idx
+
+    def _find_not_null_idx(self):
+        '''
+        Doc string
+        '''
+        
+        cc = self.spec_dict["constraints"]["custom_constraints"] or {}
+
+        not_null_idx = []
+            
+        for _, constraint in cc.items():
+
+            cc_filter = constraint.get("filter", None)
+            cc_targets = constraint.get("targets", {})
+            clean_cc_filter = clean_up_constraint_string(cc_filter)
+            cc_mask = get_constraint_mask(self.nan_data, clean_cc_filter)
+
+            for target_str, action_str in cc_targets.items():
+
+                if "make_not_null" in action_str:
+
+                    target_cols = [x.strip() for x in target_str.split(",")]
+
+                    for target in target_cols:
+
+                        not_null_idx.append(
+                            (self.nan_data.loc[cc_mask].index, target)
+                        )
+
+        return not_null_idx
diff --git a/exhibit/core/generate/weights.py b/exhibit/core/generate/weights.py
index c888a1f..46e8b55 100644
--- a/exhibit/core/generate/weights.py
+++ b/exhibit/core/generate/weights.py
@@ -1,315 +1,315 @@
-'''
-Mini module for generating the weights table & related outputs
-'''
-
-# Standard library imports
-from collections import namedtuple
-
-# External library imports
-import pandas as pd
-import numpy as np
-
-# Exhibit import
-from ..constants import MISSING_DATA_STR
-from ..utils import exceeds_inline_limit, is_paired
-from ..sql import query_exhibit_database
-
-# EXPORTABLE METHODS
-# ==================
-def generate_weights_table(spec_dict, target_cols):
-    '''
-    Lookup table for weights
-
-    Parameters
-    ----------
-    spec_dict : dict
-        original user spec
-    target_cols:
-        a subset of columns meant for the weights_table
-    
-    Returns
-    -------
-    dictionary where index levels are keys and
-    the weight column is the lookup value (as namedtuple)
-
-    Weights and probabilities should be at least 0.001;
-    even if the original, non-anonymised data has a smaller
-    probability.
-    '''
-    
-    tuple_list = []
-
-    #second element in the tuple is the column's equal weight
-    #in case we're fitting a distribution
-    Weights = namedtuple("Weights", ["weight", "equal_weight"])
-    
-    num_cols = (
-        set(spec_dict["metadata"]["numerical_columns"]) -
-        set(spec_dict["derived_columns"])
-    )
-    
-    for cat_col in target_cols:
-
-        val_count = spec_dict["columns"][cat_col]["uniques"]
-        equal_weight = 1 / val_count
-        full_anon_flag = False
-
-        #if column is put into exhibit DB, weights are always uniform
-        if exceeds_inline_limit(spec_dict, cat_col):
-
-            full_anon_flag = True
-            ws_df = _generate_weights_dataframe_from_sql(cat_col, spec_dict, num_cols)
-            
-        else:
-            #meaning, there are original_values, including weights
-            ws_df = spec_dict["columns"][cat_col]["original_values"]
-
-        #get weights and values, from whatever WS was created
-        for num_col in num_cols:
-            
-            #if numerical column is missing from original_values DF (can happen when
-            #spec is generated outside of CLI / manually), use equal_weights.
-            if num_col not in ws_df.columns: #pragma: no cover
-                ws_df[num_col] = equal_weight
-
-            ws = ws_df[num_col].astype(float)
-            # because we might've taken the FULL anon_set (150 or more), 
-            # we need to make sure the weights are correct!
-            if not full_anon_flag:
-                ws /= ws.sum() 
-            ws_vals = ws_df[cat_col]
-
-            for val, weight in zip(ws_vals, ws):
-            
-                tuple_list.append(
-                    (num_col, cat_col, val, Weights(weight, equal_weight))
-                )
-
-    #collect everything into output_df
-    output_df = pd.DataFrame(tuple_list,
-                             columns=["num_col", "cat_col", "cat_value", "weights"])
-
-    #move the indexed dataframe to dict for perfomance
-    result = (
-        output_df
-            .set_index(["num_col", "cat_col", "cat_value"])
-            .to_dict(orient="index")
-    )
-
-    return result
-
-def generate_weights(df, cat_col, num_col, ew=False):
-    '''
-    Weights are generated for a each value in each categorical column
-    where 1 means 100% of the numerical column is allocated to that value
-
-    Parameters
-    ----------
-    df : pd.DataFrame
-        source dataframe
-    cat_col : str
-        categorical column
-    num_col : str
-        numerical column
-    ew : Boolean
-        equal_weights parameter from CLI
-
-    Returns
-    -------
-    List of weights in ascending order of values rounded to 3 digits.
-    '''
-
-    # since weights are used at a per-row basis, we need to take an average of the
-    # numerical column per each categorical value to avoid over- and under-sized effects
-    # of duplicate rows. All NAs in numerical columns are treated as zeroes.
-    weights = (
-        df
-        .fillna({cat_col:MISSING_DATA_STR})
-        .groupby([cat_col], observed=True)[num_col].mean().fillna(0)
-    )
-
-    temp_output = weights.sort_index(kind="mergesort")
-
-    if MISSING_DATA_STR not in temp_output:
-        temp_output = pd.concat([temp_output, pd.Series(
-            index=[MISSING_DATA_STR],
-            data=0
-        )])
-    
-    #pop and reinsert missing data placeholder at the end of the list
-    else:
-        cached = temp_output[temp_output.index.str.contains(MISSING_DATA_STR)]
-        temp_output = temp_output.drop(MISSING_DATA_STR)
-        temp_output = pd.concat([temp_output, cached])
-
-    #equalise the weights if equal_weights is True, except for Missing data
-
-    temp_output = temp_output.transform(_weights_transform, weights=temp_output)
-
-    if ew:
-        temp_output.iloc[:-1] = round(1 / (temp_output.shape[0] - 1), 3)    
-
-    #last item in the list must be Missing data weight for the num_col, 
-    #regardless of whether Missing data is a value in cat_col
-    output = temp_output.to_list()
-
-    return output
-
-def target_columns_for_weights_table(spec_dict):
-    '''
-    Helper function to determine which columns should be used
-    in the weights table.
-    
-    Time columns and paired columns are excluded because they
-    don't in themselves contribute a different weight depending
-    on their value (time values are equal and paired columns have
-    the same weight as their parent columns).
-
-    Parameters
-    ----------
-    spec_dict : dict
-        original user specification
-    
-    Returns
-    -------
-    A set of column names
-    '''
-
-    fixed_sql_sets = ["random", "mountains", "birds", "patients"]
-
-    cat_cols = spec_dict["metadata"]["categorical_columns"] #includes linked
-    cat_cols_set = set(cat_cols)
-
-    #drop columns, like(paired / regex columns) that we don't expect to have num. weights
-    for cat_col in cat_cols:
-        anon_set = spec_dict["columns"][cat_col]["anonymising_set"]
-
-        # if we're missing original_values, there can be no weights
-        orig_vals = spec_dict["columns"][cat_col]["original_values"]
-        if orig_vals is None or (isinstance(orig_vals, pd.DataFrame) and orig_vals.empty): #pragma: no cover
-            cat_cols_set.remove(cat_col)
-            continue
-
-        # skip the checks for custom functions
-        if callable(anon_set):
-            continue
-        if (
-            is_paired(spec_dict, cat_col) or
-            # we keep the columns if they are in fixed sets or have custom SQL;
-            # because regex can be very variable, we assume that if anoymising set is not in
-            # fixed sets, and doesn't start with SELECT, it's regex and shouldn't have weights
-            (anon_set.split(".")[0] not in fixed_sql_sets and
-            anon_set.strip().upper()[:6] != "SELECT") 
-            ):
-            cat_cols_set.remove(cat_col)
-
-    return cat_cols_set
-
-# INNER MODULE METHODS
-# ====================
-def _generate_weights_dataframe_from_sql(cat_col, spec_dict, num_cols):
-    '''
-    Function to create a weights dataframe for a categorical column
-    whose values are drawn from exhibit DB.
-
-    There are 4 of possible scenarios:
-     - random shuffle of existing values in a linked column
-     - random shuffle of existing values in a standalone column
-     - values drawn from an anonymising set for a linked column
-     - values drawn from an anonymising set for a standaline column
-
-    Anonymising set for a linked group is often given just by its name,
-    like "mountains" which means we need to loop over ALL linked groups
-    and ALL linked columns within them to find the exact right linked column.
-
-    '''
-
-    table_id = spec_dict["metadata"]["id"]
-    linked_groups = spec_dict.get("linked_columns", [])
-    anon_set = spec_dict["columns"][cat_col]["anonymising_set"]
-    val_count = spec_dict["columns"][cat_col]["uniques"]
-
-    #determine the source of the data (table_name and sql_column)
-    if anon_set != "random":
-        
-        table_name, *sql_column = anon_set.split(".")
-
-        #if column is part of linked group and set is multi-column
-        #table_name will still be equal to anon_set, but sql_column
-        #will have to depend on column's position in linked group
-        if not sql_column:
-
-            for linked_group in linked_groups:
-                for i, col in enumerate(linked_group[1]):
-                    if col == cat_col:
-                        col_pos = i
-
-            ws_df = pd.DataFrame(
-                data=(
-                    query_exhibit_database(table_name)
-                        .iloc[:, col_pos]
-                        .drop_duplicates()
-                )
-            )
-            
-            #rename columns to match the source
-            ws_df.columns = [cat_col]
-
-        else:
-            ws_df = pd.DataFrame(
-                data=query_exhibit_database(table_name, sql_column)
-            )
-            #rename columns to match the source
-            ws_df.columns = [cat_col]
-        
-    else:
-        #two options:
-        #either column is part if a linked group which means
-        #the table_name is for the linked group, not column
-        #or column is saved into db under its own name
-
-        for linked_group in linked_groups:
-
-            # skip the zero-th linked group reserved for user defined linkage
-            if linked_group[0] == 0:
-                continue
-
-            if cat_col in linked_group[1]:
-
-                table_name = f"temp_{table_id}_{linked_group[0]}"
-                sql_column = cat_col.replace(" ", "$")
-                
-                ws_df = pd.DataFrame(
-                    data=query_exhibit_database(table_name, sql_column)
-                )
-                break
-
-        else:
-
-            table_name = f"temp_{table_id}_{cat_col.replace(' ', '$')}"
-            ws_df = pd.DataFrame(
-                data=query_exhibit_database(table_name)
-            )
-    
-    #Finally, generate equal weights for the column and put into weights_df
-    for num_col in num_cols:
-        ws_df[num_col] = 1 / val_count
-    
-    return ws_df
-
-def _weights_transform(x, weights):
-    '''
-    Transform weights values, including zeroes and NaNs, to 
-    be betweeen 0.001 and 1.
-
-    Vectorise this function!
-    '''
-    
-    if x == 0:
-        return 0
-
-    if np.isnan(x): # pragma: no cover
-        return np.NaN
-    
-    return max(0.001, round(x / weights.sum(), 3))
+'''
+Mini module for generating the weights table & related outputs
+'''
+
+# Standard library imports
+from collections import namedtuple
+
+# External library imports
+import pandas as pd
+import numpy as np
+
+# Exhibit import
+from ..constants import MISSING_DATA_STR
+from ..utils import exceeds_inline_limit, is_paired
+from ..sql import query_exhibit_database
+
+# EXPORTABLE METHODS
+# ==================
+def generate_weights_table(spec_dict, target_cols):
+    '''
+    Lookup table for weights
+
+    Parameters
+    ----------
+    spec_dict : dict
+        original user spec
+    target_cols:
+        a subset of columns meant for the weights_table
+    
+    Returns
+    -------
+    dictionary where index levels are keys and
+    the weight column is the lookup value (as namedtuple)
+
+    Weights and probabilities should be at least 0.001;
+    even if the original, non-anonymised data has a smaller
+    probability.
+    '''
+    
+    tuple_list = []
+
+    #second element in the tuple is the column's equal weight
+    #in case we're fitting a distribution
+    Weights = namedtuple("Weights", ["weight", "equal_weight"])
+    
+    num_cols = (
+        set(spec_dict["metadata"]["numerical_columns"]) -
+        set(spec_dict["derived_columns"])
+    )
+    
+    for cat_col in target_cols:
+
+        val_count = spec_dict["columns"][cat_col]["uniques"]
+        equal_weight = 1 / val_count
+        full_anon_flag = False
+
+        #if column is put into exhibit DB, weights are always uniform
+        if exceeds_inline_limit(spec_dict, cat_col):
+
+            full_anon_flag = True
+            ws_df = _generate_weights_dataframe_from_sql(cat_col, spec_dict, num_cols)
+            
+        else:
+            #meaning, there are original_values, including weights
+            ws_df = spec_dict["columns"][cat_col]["original_values"]
+
+        #get weights and values, from whatever WS was created
+        for num_col in num_cols:
+            
+            #if numerical column is missing from original_values DF (can happen when
+            #spec is generated outside of CLI / manually), use equal_weights.
+            if num_col not in ws_df.columns: #pragma: no cover
+                ws_df[num_col] = equal_weight
+
+            ws = ws_df[num_col].astype(float)
+            # because we might've taken the FULL anon_set (150 or more), 
+            # we need to make sure the weights are correct!
+            if not full_anon_flag:
+                ws /= ws.sum() 
+            ws_vals = ws_df[cat_col]
+
+            for val, weight in zip(ws_vals, ws):
+            
+                tuple_list.append(
+                    (num_col, cat_col, val, Weights(weight, equal_weight))
+                )
+
+    #collect everything into output_df
+    output_df = pd.DataFrame(tuple_list,
+                             columns=["num_col", "cat_col", "cat_value", "weights"])
+
+    #move the indexed dataframe to dict for perfomance
+    result = (
+        output_df
+            .set_index(["num_col", "cat_col", "cat_value"])
+            .to_dict(orient="index")
+    )
+
+    return result
+
+def generate_weights(df, cat_col, num_col, ew=False):
+    '''
+    Weights are generated for a each value in each categorical column
+    where 1 means 100% of the numerical column is allocated to that value
+
+    Parameters
+    ----------
+    df : pd.DataFrame
+        source dataframe
+    cat_col : str
+        categorical column
+    num_col : str
+        numerical column
+    ew : Boolean
+        equal_weights parameter from CLI
+
+    Returns
+    -------
+    List of weights in ascending order of values rounded to 3 digits.
+    '''
+
+    # since weights are used at a per-row basis, we need to take an average of the
+    # numerical column per each categorical value to avoid over- and under-sized effects
+    # of duplicate rows. All NAs in numerical columns are treated as zeroes.
+    weights = (
+        df
+        .fillna({cat_col:MISSING_DATA_STR})
+        .groupby([cat_col], observed=True)[num_col].mean().fillna(0)
+    )
+
+    temp_output = weights.sort_index(kind="mergesort")
+
+    if MISSING_DATA_STR not in temp_output:
+        temp_output = pd.concat([temp_output, pd.Series(
+            index=[MISSING_DATA_STR],
+            data=0
+        )])
+    
+    #pop and reinsert missing data placeholder at the end of the list
+    else:
+        cached = temp_output[temp_output.index.str.contains(MISSING_DATA_STR)]
+        temp_output = temp_output.drop(MISSING_DATA_STR)
+        temp_output = pd.concat([temp_output, cached])
+
+    #equalise the weights if equal_weights is True, except for Missing data
+
+    temp_output = temp_output.transform(_weights_transform, weights=temp_output)
+
+    if ew:
+        temp_output.iloc[:-1] = round(1 / (temp_output.shape[0] - 1), 3)    
+
+    #last item in the list must be Missing data weight for the num_col, 
+    #regardless of whether Missing data is a value in cat_col
+    output = temp_output.to_list()
+
+    return output
+
+def target_columns_for_weights_table(spec_dict):
+    '''
+    Helper function to determine which columns should be used
+    in the weights table.
+    
+    Time columns and paired columns are excluded because they
+    don't in themselves contribute a different weight depending
+    on their value (time values are equal and paired columns have
+    the same weight as their parent columns).
+
+    Parameters
+    ----------
+    spec_dict : dict
+        original user specification
+    
+    Returns
+    -------
+    A set of column names
+    '''
+
+    fixed_sql_sets = ["random", "mountains", "birds", "patients"]
+
+    cat_cols = spec_dict["metadata"]["categorical_columns"] #includes linked
+    cat_cols_set = set(cat_cols)
+
+    #drop columns, like(paired / regex columns) that we don't expect to have num. weights
+    for cat_col in cat_cols:
+        anon_set = spec_dict["columns"][cat_col]["anonymising_set"]
+
+        # if we're missing original_values, there can be no weights
+        orig_vals = spec_dict["columns"][cat_col]["original_values"]
+        if orig_vals is None or (isinstance(orig_vals, pd.DataFrame) and orig_vals.empty): #pragma: no cover
+            cat_cols_set.remove(cat_col)
+            continue
+
+        # skip the checks for custom functions
+        if callable(anon_set):
+            continue
+        if (
+            is_paired(spec_dict, cat_col) or
+            # we keep the columns if they are in fixed sets or have custom SQL;
+            # because regex can be very variable, we assume that if anoymising set is not in
+            # fixed sets, and doesn't start with SELECT, it's regex and shouldn't have weights
+            (anon_set.split(".")[0] not in fixed_sql_sets and
+            anon_set.strip().upper()[:6] != "SELECT") 
+            ):
+            cat_cols_set.remove(cat_col)
+
+    return cat_cols_set
+
+# INNER MODULE METHODS
+# ====================
+def _generate_weights_dataframe_from_sql(cat_col, spec_dict, num_cols):
+    '''
+    Function to create a weights dataframe for a categorical column
+    whose values are drawn from exhibit DB.
+
+    There are 4 of possible scenarios:
+     - random shuffle of existing values in a linked column
+     - random shuffle of existing values in a standalone column
+     - values drawn from an anonymising set for a linked column
+     - values drawn from an anonymising set for a standaline column
+
+    Anonymising set for a linked group is often given just by its name,
+    like "mountains" which means we need to loop over ALL linked groups
+    and ALL linked columns within them to find the exact right linked column.
+
+    '''
+
+    table_id = spec_dict["metadata"]["id"]
+    linked_groups = spec_dict.get("linked_columns", [])
+    anon_set = spec_dict["columns"][cat_col]["anonymising_set"]
+    val_count = spec_dict["columns"][cat_col]["uniques"]
+
+    #determine the source of the data (table_name and sql_column)
+    if anon_set != "random":
+        
+        table_name, *sql_column = anon_set.split(".")
+
+        #if column is part of linked group and set is multi-column
+        #table_name will still be equal to anon_set, but sql_column
+        #will have to depend on column's position in linked group
+        if not sql_column:
+
+            for linked_group in linked_groups:
+                for i, col in enumerate(linked_group[1]):
+                    if col == cat_col:
+                        col_pos = i
+
+            ws_df = pd.DataFrame(
+                data=(
+                    query_exhibit_database(table_name)
+                        .iloc[:, col_pos]
+                        .drop_duplicates()
+                )
+            )
+            
+            #rename columns to match the source
+            ws_df.columns = [cat_col]
+
+        else:
+            ws_df = pd.DataFrame(
+                data=query_exhibit_database(table_name, sql_column)
+            )
+            #rename columns to match the source
+            ws_df.columns = [cat_col]
+        
+    else:
+        #two options:
+        #either column is part if a linked group which means
+        #the table_name is for the linked group, not column
+        #or column is saved into db under its own name
+
+        for linked_group in linked_groups:
+
+            # skip the zero-th linked group reserved for user defined linkage
+            if linked_group[0] == 0:
+                continue
+
+            if cat_col in linked_group[1]:
+
+                table_name = f"temp_{table_id}_{linked_group[0]}"
+                sql_column = cat_col.replace(" ", "$")
+                
+                ws_df = pd.DataFrame(
+                    data=query_exhibit_database(table_name, sql_column)
+                )
+                break
+
+        else:
+
+            table_name = f"temp_{table_id}_{cat_col.replace(' ', '$')}"
+            ws_df = pd.DataFrame(
+                data=query_exhibit_database(table_name)
+            )
+    
+    #Finally, generate equal weights for the column and put into weights_df
+    for num_col in num_cols:
+        ws_df[num_col] = 1 / val_count
+    
+    return ws_df
+
+def _weights_transform(x, weights):
+    '''
+    Transform weights values, including zeroes and NaNs, to 
+    be betweeen 0.001 and 1.
+
+    Vectorise this function!
+    '''
+    
+    if x == 0:
+        return 0
+
+    if np.isnan(x): # pragma: no cover
+        return np.nan
+    
+    return max(0.001, round(x / weights.sum(), 3))
     
\ No newline at end of file
diff --git a/exhibit/core/linkage/hierarchical.py b/exhibit/core/linkage/hierarchical.py
index 0e66171..22e1600 100644
--- a/exhibit/core/linkage/hierarchical.py
+++ b/exhibit/core/linkage/hierarchical.py
@@ -637,7 +637,7 @@ def scenario_2(self):
             orig_df = self.spec_dict["columns"][self.base_col]["original_values"]
             repl = self.sql_df[self.base_col].unique()[0:self.base_col_unique_count]
             aliases = dict(zip(orig_df[self.base_col].values[:-1], repl))
-            aliased_df = orig_df.applymap(lambda x: aliases.get(x, x))
+            aliased_df = orig_df.map(lambda x: aliases.get(x, x))
             self.spec_dict["columns"][self.base_col]["original_values"] = aliased_df
             base_col_vals = aliased_df[self.base_col].iloc[:-1].unique()
 
@@ -699,7 +699,7 @@ def scenario_3(self):
             orig_df = self.spec_dict["columns"][self.base_col]["original_values"]
             repl = self.sql_df[self.base_col].unique()[0:self.base_col_unique_count]
             aliases = dict(zip(orig_df[self.base_col].values[:-1], repl))
-            aliased_df = orig_df.applymap(lambda x: aliases.get(x, x))
+            aliased_df = orig_df.map(lambda x: aliases.get(x, x))
             self.spec_dict["columns"][self.base_col]["original_values"] = aliased_df
             base_col_vals = aliased_df[self.base_col].iloc[:-1].unique()
 
diff --git a/exhibit/core/linkage/matrix.py b/exhibit/core/linkage/matrix.py
index 43c662a..66107dc 100644
--- a/exhibit/core/linkage/matrix.py
+++ b/exhibit/core/linkage/matrix.py
@@ -1,389 +1,389 @@
-'''
-Module isolating methods and classes to find, process and generate
-user-defined linked columns where the relationships are coded in a
-lookup + matrix. For hierarchical linkage see the hierarchical module,
-'''
-
-# Standard library imports
-import sys
-import textwrap
-from functools import partial
-from multiprocessing import Pool
-
-# External imports
-import numpy as np
-import pandas as pd
-
-# Exhibit imports
-from ..constants import MISSING_DATA_STR
-from ..sql import create_temp_table, query_exhibit_database
-
-def save_predefined_linked_cols_to_db(df, id):
-    """
-    Derive and save everything that's required to generate
-    user defined linked columns on demand from a future spec
-
-    Parameters
-    ----------
-    df : pd.DataFrame
-        original dataframe with just the categorical columns;
-        we assume that linked columns defined by the user are
-        categorical. Maybe need a special case for time?
-    id : str
-        taken from metadata[id]
-
-    Returns
-    -------
-    nothing
-    """
-    
-    prefixed_df = add_prefix(df)
-    orig_label_to_pos_label = {} # age__0-9 : age__0, etc.
-    pos_labels_inc_column = []   # age__0, age__1, etc.
-    sep = "__"
-
-    for col in prefixed_df.columns:
-
-        col_vals = sorted(prefixed_df[col].unique())
-
-        # add Missing data by hand if not already there OR
-        # pop and reinsert at the end to align with the spec!
-        # make sure the values are sorted AFTER we remove the existing
-        # Missing data, but BEFORE we reinsert it.
-        col_miss_val = f"{col}{sep}{MISSING_DATA_STR}"
-
-        # don't forget that we need to test equality element-wise, hence conversion
-        # to an array from; lists don't compare in the same way.
-        if col_miss_val in col_vals:
-            col_vals = sorted(np.delete(col_vals, np.array(col_vals) == col_miss_val))
-
-        col_vals = np.append(col_vals, col_miss_val)
-
-        pos_labels_temp = [
-            f"{col}{sep}{x}" for x in range(len(col_vals))
-            ]
-
-        pos_labels_inc_column.extend(pos_labels_temp)
-
-        orig_label_to_pos_label.update(
-            {k:v for v, k in zip(pos_labels_temp, col_vals)}
-        )
-
-    # age__0 : 0, etc.
-    pos_label_to_id = dict(
-        zip(pos_labels_inc_column, range(len(pos_labels_inc_column))) 
-        ) 
-
-    # convert the original, prefixed values first to positional labels
-    # and then just to numerical IDs
-    temp_df = (prefixed_df
-            .applymap(lambda x: orig_label_to_pos_label.get(x, x))
-            .applymap(lambda x: pos_label_to_id.get(x, x)))
-
-    label_matrix = np.unique(temp_df.values, axis=0).astype(np.intc)
-
-    # make sure column names don't have spaces
-    col_names = [x.replace(" ", "$") for x in prefixed_df.columns]
-
-    # save the label matrix to SQLite db
-    create_temp_table(
-        table_name=f"temp_{id}_matrix",
-        col_names=col_names,
-        data=label_matrix,
-    )
-
-    # save the lookup to SQLite db; note that numerical_ids are
-    # upcast to strings by numpy when creating the array!
-    create_temp_table(
-        table_name=f"temp_{id}_lookup",
-        col_names=["pos_label", "num_label"],
-        data=list(pos_label_to_id.items()),
-    )
-
-def add_prefix(df, sep="__"):
-    """
-    Add column name as prefix to the column values
-
-    Parameters
-    ----------
-    df : pd.DataFrame
-        df must have purely categorical columns - no checks are made
-    sep : str, optional
-        separator must be consistent between add_prefix and remove_prefix
-        by default "__"
-
-    Returns
-    -------
-    new DataFrame where values are prefixed with column name
-    """
-
-    data_dict = {}
-    
-    for col in df.columns:
-        # cast to str in case we're dealing with integer-based categorical columns, like age
-        df_col_str = df[col].fillna(MISSING_DATA_STR).astype(str)
-        data_dict[col] = np.add(f"{col}{sep}", df_col_str.values)
-        
-    return pd.DataFrame(data_dict)
-
-def generate_user_linked_anon_df(
-    spec_dict, linked_cols, num_rows, starting_col_matrix=None):
-    '''
-    Main function to generated user-defined linked columns.
-
-    Parameters
-    ----------
-    spec_dict    : dictionary
-        specification plus internal keys, like _rng
-    linked_cols : list
-        there can be only one user-linked group (0, [linked_col_1, linked_col_2, ])
-    num_rows     : int
-        number of rows to generate
-    starting_col_matrix : np.Array shaped (num_rows, len(linked_cols))
-        the matrix is either filled with None values or pre-populated if the function
-        is run multiple times (like when regenerating values after applying custom 
-        actions like make_same)
-
-    Returns
-    -------
-    Data Frame with linked columns
-    '''
-    
-    table_id = spec_dict["metadata"]["id"]
-    rng = spec_dict["_rng"]
-    lookup, matrix = get_lookup_and_matrix_from_db(table_id)
-    new_label_lookup, proba_lookup = build_new_lookups(spec_dict, linked_cols, lookup)
-    # DANGER WHEN REVERSING THE DICT - SAME VALUES IN MULTIPLE COLUMNS WILL BE LOST
-    rev_label_lookup = {key:value for value, key in new_label_lookup.items()}
-    # linked columns dispersion list
-    lcd = [spec_dict["columns"][col]["dispersion"] for col in linked_cols]
-
-    # if re-creating linked values from a pre-generated sequence, reverse the dict to
-    # get the numerical mapping as expected, also changing the dtype for performance.
-
-    if starting_col_matrix is not None:
-        starting_col_matrix = (
-            pd.DataFrame(starting_col_matrix)
-            .fillna(MISSING_DATA_STR)
-            .applymap(lambda x: rev_label_lookup.get(x, x)).values.astype(np.int16)
-        )
-
-    else:
-        starting_col_matrix = np.full(
-            shape=(num_rows, len(linked_cols)), fill_value=-1)
-
-    # multiprocessing only on unix
-    if sys.platform != "win32":
-        with Pool(processes=4) as pool:
-
-            new_rows = pool.map(
-                partial(process_row, matrix, proba_lookup, lcd, rng),
-                starting_col_matrix
-                )
-    else: #pragma: no cover
-
-        new_rows = []
-
-        for i in range(num_rows):
-            new_row = process_row(
-                matrix, proba_lookup, lcd, rng, starting_col_matrix[i])
-            new_rows.append(new_row)
-
-    new_matrix = np.stack(new_rows)
-
-    new_df = pd.DataFrame(
-        new_matrix, columns=linked_cols).applymap(lambda x: new_label_lookup.get(x, x))
-
-    return new_df
-
-def get_lookup_and_matrix_from_db(table_id):
-    '''
-    The names of the two tables required for user defined linkage don't change:
-    one is lookup and another is matrix.
-    '''
-
-    lookup = dict(query_exhibit_database(f"temp_{table_id}_lookup").values)
-    matrix = query_exhibit_database(f"temp_{table_id}_matrix").values
-
-    return lookup, matrix
-
-def process_row(
-    label_matrix, proba_lookup, lcd, rng, ref_array, acc_array=None, i=0):
-    '''
-    Recursive function to generate new rows of data from the 
-    existing linked matrix. It's possible the function will be 
-    called multiple times to generate a column value if there
-    are no valid values that follow on from earlier values in the sequence.
-
-    For example, if A => A1 => A11 and B => B2 => B12 then if the second
-    column has dispersion set to > 0, the row generation might go like this:
-    A => B2 (due to dispersion) => B12 (falling back to a valid 2-member sequence 
-    rather than generating a random value because there isn't a A => B2 predefined
-    in the linkage matrix taken from the original data).
-
-    Parameters
-    ----------
-    label_matrix      : np.array
-        array where shape[0] is the number_unique_combinations_of_all_linked_col_values
-        and shape[1] is the number of linked columns
-    proba_lookup      : dictionary
-        dictionary where keys are encoded original values (0, 1, 2, etc.) and values
-        are their probabilities taken either from the specification or equalised from db
-    lcd               : list
-        list with dispersion values for each column in linked_columns
-    rng               : np.rng
-        shared RNG generator
-    ref_array         : np.Array
-        array of either None values or pre-populated with existing df values
-    acc_array         : np.Array
-        accummulated array that is being processed and returned
-    i                 : integer
-        a counter in case we need to reduce the sequence size to check for valid
-        combinations to determine the next valid value 
-
-    Returns
-    -------
-    np.array of a single row with encoded column values
-    '''
-
-    if acc_array is None:
-        acc_array = np.array([])
-      
-    arr_len = len(acc_array)
-    ref_arr_len = len(ref_array)
-    
-    if arr_len == label_matrix.shape[1]:
-        return acc_array
-
-    # if there are no valid targets due to dispersion throwing in a non-valid target,
-    # rather than continue checking the full array (which will always fail to produce
-    # a valid next value), change the first position of the array being checked from 0
-    # to counter i and increase until you exhaust the prior possibilities. The fallback
-    # is that there will always be valid targets for previous sequence length = 1 aka
-    # from one column to the next.
-    
-    _ref_array = np.where(ref_array == -1, label_matrix, ref_array)
-    mask = np.all(label_matrix[:, i:ref_arr_len] == _ref_array[:, i:], axis=1)
-
-    valid_targets = np.unique(label_matrix[mask, arr_len])
-
-    if len(valid_targets) == 0:
-
-        i = i + 1
-        return process_row(
-            label_matrix, proba_lookup, lcd, rng, ref_array, acc_array, i)
-        
-    target_proba = np.array([proba_lookup[x] for x in valid_targets])
-
-    # typically, there will be more than 1 value in target_proba, but we have to guard against
-    # possibility of there being just one value, and if its probability is zero (Missing data)
-    # then summing it to 1 will result in NA (division by zero). As a workaround, set proba to
-    # 1 whenever it's the only possible value - since having it less than 1 doesn't make sense.
-    if len(target_proba) == 1:
-        target_proba = np.array([1])
-
-    # make sure the probabilities sum up to 1
-    target_proba = target_proba * (1 / sum(target_proba))
-
-    # take dispersion from the spec
-    dispersion = lcd[arr_len]
-
-    # default is to pick a random valid target
-    next_val = rng.choice(a=valid_targets, p=target_proba)
-
-    # except when it's already pre-generated
-    if ref_array[arr_len] != -1:
-        next_val = ref_array[arr_len]
-
-    # or dispersion is in effect; this part is expensive so only calculate if needed
-    elif dispersion and rng.random() < dispersion:
-        all_targets = np.unique(label_matrix[:, arr_len])
-        non_valid_targets = np.setdiff1d(all_targets, valid_targets)
-        if len(non_valid_targets) > 0:
-            next_val = rng.choice(a=non_valid_targets)
-
-    new_array = np.append(acc_array, next_val)
-
-    # update the ref_array to capture the just generated value
-    if ref_array[arr_len] == -1:
-        ref_array[arr_len] = next_val
-    
-    return process_row(label_matrix, proba_lookup, lcd, rng, ref_array, new_array)
-
-def build_new_lookups(spec_dict, linked_cols, original_lookup):
-    '''
-    Build two lookups: 
-        - from the numerical id to its aliased value. {0: 'hb_code__S08000015', ...}
-        - from the numerical id to the probability value {0: 0.5}
-         
-    Be mindful of all the intermediate steps. The intermediate lookup is created
-    with the numerical ID to a tuple and then split into two.
-
-    original_lookup is a positional to numerical_id, like so:
-        {'hb_code__0': 0} which is to say that the zero-th value in the list of
-        all hb_code values is aliased to the numerical id zero.
-
-    Special case if original values are not stored in the spec, but instead have
-    been put into the DB
-    '''
-
-    pos_labels_inc_column = []   # age__0, age__1, etc.
-    pos_label_to_orig_tuple = {} # age__0: (age__0-9, 0.5), etc.
-
-    for col in linked_cols:
-        
-        orig_vals = spec_dict["columns"][col]["original_values"]
-        prob_vector = None
-
-        if not isinstance(orig_vals, pd.DataFrame):
-
-            safe_col = col.replace(" ", "$")
-            table_id = spec_dict["metadata"]["id"]
-            orig_vals_db = query_exhibit_database(table_name=f"temp_{table_id}_{safe_col}")
-            orig_vals_sorted = (
-                sorted([x for x in orig_vals_db[col] if x != MISSING_DATA_STR]) + 
-                [MISSING_DATA_STR]
-            )
-
-            orig_vals = pd.DataFrame(data={col:orig_vals_sorted})
-
-            if "probability_vector" not in orig_vals_db.columns:
-                prob_vector = np.ones(orig_vals.shape[0])
-                prob_vector[-1] = spec_dict["columns"][col]["miss_probability"]
-            else:
-                prob_vector = orig_vals_db["probability_vector"].astype(float).values
-                prob_vector = np.append(
-                    prob_vector, spec_dict["columns"][col]["miss_probability"])          
-
-            prob_vector /= prob_vector.sum()
-
-        if prob_vector is None:
-            prob_vector = orig_vals["probability_vector"].values
-        
-        pos_labels_temp = [f"{col}__{x}" for x in range(len(orig_vals[col].values))]
-        pos_labels_inc_column.extend(pos_labels_temp)
-        pos_label_to_orig_tuple.update(
-            dict(zip(
-                pos_labels_temp, tuple(zip(orig_vals[col].values, prob_vector))
-            ))
-        )
-
-    # 0: age__0, etc. using the ORIGINAL lookup which has all the relationships
-    id_to_pos_label = {v:k for k, v in original_lookup.items()}
-
-    # if we don't check for the user removed values here, the next line
-    # will error out with an obscure Key not found message. 
-    if len(original_lookup) != len(pos_label_to_orig_tuple):
-        raise ValueError(textwrap.dedent("""
-        The number of values in user linked columns doesn't match original data.
-        If you would like to remove values, set their probability to zero.
-        """))
-
-    # 0: 'hb_code__aliased_code'
-    rev_labels = {k: pos_label_to_orig_tuple[v] for k, v in id_to_pos_label.items()}
-
-    # finally, split the tuple dictionary into two separate ones:
-    label_lookup = {k:v[0] for k, v in rev_labels.items()}
-    proba_lookup = {k:v[1] for k, v in rev_labels.items()}
-
-    return label_lookup, proba_lookup
+'''
+Module isolating methods and classes to find, process and generate
+user-defined linked columns where the relationships are coded in a
+lookup + matrix. For hierarchical linkage see the hierarchical module,
+'''
+
+# Standard library imports
+import sys
+import textwrap
+from functools import partial
+from multiprocessing import Pool
+
+# External imports
+import numpy as np
+import pandas as pd
+
+# Exhibit imports
+from ..constants import MISSING_DATA_STR
+from ..sql import create_temp_table, query_exhibit_database
+
+def save_predefined_linked_cols_to_db(df, id):
+    """
+    Derive and save everything that's required to generate
+    user defined linked columns on demand from a future spec
+
+    Parameters
+    ----------
+    df : pd.DataFrame
+        original dataframe with just the categorical columns;
+        we assume that linked columns defined by the user are
+        categorical. Maybe need a special case for time?
+    id : str
+        taken from metadata[id]
+
+    Returns
+    -------
+    nothing
+    """
+    
+    prefixed_df = add_prefix(df)
+    orig_label_to_pos_label = {} # age__0-9 : age__0, etc.
+    pos_labels_inc_column = []   # age__0, age__1, etc.
+    sep = "__"
+
+    for col in prefixed_df.columns:
+
+        col_vals = sorted(prefixed_df[col].unique())
+
+        # add Missing data by hand if not already there OR
+        # pop and reinsert at the end to align with the spec!
+        # make sure the values are sorted AFTER we remove the existing
+        # Missing data, but BEFORE we reinsert it.
+        col_miss_val = f"{col}{sep}{MISSING_DATA_STR}"
+
+        # don't forget that we need to test equality element-wise, hence conversion
+        # to an array from; lists don't compare in the same way.
+        if col_miss_val in col_vals:
+            col_vals = sorted(np.delete(col_vals, np.array(col_vals) == col_miss_val))
+
+        col_vals = np.append(col_vals, col_miss_val)
+
+        pos_labels_temp = [
+            f"{col}{sep}{x}" for x in range(len(col_vals))
+            ]
+
+        pos_labels_inc_column.extend(pos_labels_temp)
+
+        orig_label_to_pos_label.update(
+            {k:v for v, k in zip(pos_labels_temp, col_vals)}
+        )
+
+    # age__0 : 0, etc.
+    pos_label_to_id = dict(
+        zip(pos_labels_inc_column, range(len(pos_labels_inc_column))) 
+        ) 
+
+    # convert the original, prefixed values first to positional labels
+    # and then just to numerical IDs
+    temp_df = (prefixed_df
+            .map(lambda x: orig_label_to_pos_label.get(x, x))
+            .map(lambda x: pos_label_to_id.get(x, x)))
+
+    label_matrix = np.unique(temp_df.values, axis=0).astype(np.intc)
+
+    # make sure column names don't have spaces
+    col_names = [x.replace(" ", "$") for x in prefixed_df.columns]
+
+    # save the label matrix to SQLite db
+    create_temp_table(
+        table_name=f"temp_{id}_matrix",
+        col_names=col_names,
+        data=label_matrix,
+    )
+
+    # save the lookup to SQLite db; note that numerical_ids are
+    # upcast to strings by numpy when creating the array!
+    create_temp_table(
+        table_name=f"temp_{id}_lookup",
+        col_names=["pos_label", "num_label"],
+        data=list(pos_label_to_id.items()),
+    )
+
+def add_prefix(df, sep="__"):
+    """
+    Add column name as prefix to the column values
+
+    Parameters
+    ----------
+    df : pd.DataFrame
+        df must have purely categorical columns - no checks are made
+    sep : str, optional
+        separator must be consistent between add_prefix and remove_prefix
+        by default "__"
+
+    Returns
+    -------
+    new DataFrame where values are prefixed with column name
+    """
+
+    data_dict = {}
+    
+    for col in df.columns:
+        # cast to str in case we're dealing with integer-based categorical columns, like age
+        df_col_str = df[col].fillna(MISSING_DATA_STR).astype(str)
+        data_dict[col] = np.add(f"{col}{sep}", df_col_str.values)
+        
+    return pd.DataFrame(data_dict)
+
+def generate_user_linked_anon_df(
+    spec_dict, linked_cols, num_rows, starting_col_matrix=None):
+    '''
+    Main function to generated user-defined linked columns.
+
+    Parameters
+    ----------
+    spec_dict    : dictionary
+        specification plus internal keys, like _rng
+    linked_cols : list
+        there can be only one user-linked group (0, [linked_col_1, linked_col_2, ])
+    num_rows     : int
+        number of rows to generate
+    starting_col_matrix : np.Array shaped (num_rows, len(linked_cols))
+        the matrix is either filled with None values or pre-populated if the function
+        is run multiple times (like when regenerating values after applying custom 
+        actions like make_same)
+
+    Returns
+    -------
+    Data Frame with linked columns
+    '''
+    
+    table_id = spec_dict["metadata"]["id"]
+    rng = spec_dict["_rng"]
+    lookup, matrix = get_lookup_and_matrix_from_db(table_id)
+    new_label_lookup, proba_lookup = build_new_lookups(spec_dict, linked_cols, lookup)
+    # DANGER WHEN REVERSING THE DICT - SAME VALUES IN MULTIPLE COLUMNS WILL BE LOST
+    rev_label_lookup = {key:value for value, key in new_label_lookup.items()}
+    # linked columns dispersion list
+    lcd = [spec_dict["columns"][col]["dispersion"] for col in linked_cols]
+
+    # if re-creating linked values from a pre-generated sequence, reverse the dict to
+    # get the numerical mapping as expected, also changing the dtype for performance.
+
+    if starting_col_matrix is not None:
+        starting_col_matrix = (
+            pd.DataFrame(starting_col_matrix).infer_objects(copy=False)
+            .fillna(MISSING_DATA_STR)
+            .map(lambda x: rev_label_lookup.get(x, x)).values.astype(np.int16)
+        )
+
+    else:
+        starting_col_matrix = np.full(
+            shape=(num_rows, len(linked_cols)), fill_value=-1)
+
+    # multiprocessing only on unix
+    if sys.platform != "win32":
+        with Pool(processes=4) as pool:
+
+            new_rows = pool.map(
+                partial(process_row, matrix, proba_lookup, lcd, rng),
+                starting_col_matrix
+                )
+    else: #pragma: no cover
+
+        new_rows = []
+
+        for i in range(num_rows):
+            new_row = process_row(
+                matrix, proba_lookup, lcd, rng, starting_col_matrix[i])
+            new_rows.append(new_row)
+
+    new_matrix = np.stack(new_rows)
+
+    new_df = pd.DataFrame(
+        new_matrix, columns=linked_cols).map(lambda x: new_label_lookup.get(x, x))
+
+    return new_df
+
+def get_lookup_and_matrix_from_db(table_id):
+    '''
+    The names of the two tables required for user defined linkage don't change:
+    one is lookup and another is matrix.
+    '''
+
+    lookup = dict(query_exhibit_database(f"temp_{table_id}_lookup").values)
+    matrix = query_exhibit_database(f"temp_{table_id}_matrix").values
+
+    return lookup, matrix
+
+def process_row(
+    label_matrix, proba_lookup, lcd, rng, ref_array, acc_array=None, i=0):
+    '''
+    Recursive function to generate new rows of data from the 
+    existing linked matrix. It's possible the function will be 
+    called multiple times to generate a column value if there
+    are no valid values that follow on from earlier values in the sequence.
+
+    For example, if A => A1 => A11 and B => B2 => B12 then if the second
+    column has dispersion set to > 0, the row generation might go like this:
+    A => B2 (due to dispersion) => B12 (falling back to a valid 2-member sequence 
+    rather than generating a random value because there isn't a A => B2 predefined
+    in the linkage matrix taken from the original data).
+
+    Parameters
+    ----------
+    label_matrix      : np.array
+        array where shape[0] is the number_unique_combinations_of_all_linked_col_values
+        and shape[1] is the number of linked columns
+    proba_lookup      : dictionary
+        dictionary where keys are encoded original values (0, 1, 2, etc.) and values
+        are their probabilities taken either from the specification or equalised from db
+    lcd               : list
+        list with dispersion values for each column in linked_columns
+    rng               : np.rng
+        shared RNG generator
+    ref_array         : np.Array
+        array of either None values or pre-populated with existing df values
+    acc_array         : np.Array
+        accummulated array that is being processed and returned
+    i                 : integer
+        a counter in case we need to reduce the sequence size to check for valid
+        combinations to determine the next valid value 
+
+    Returns
+    -------
+    np.array of a single row with encoded column values
+    '''
+
+    if acc_array is None:
+        acc_array = np.array([])
+      
+    arr_len = len(acc_array)
+    ref_arr_len = len(ref_array)
+    
+    if arr_len == label_matrix.shape[1]:
+        return acc_array
+
+    # if there are no valid targets due to dispersion throwing in a non-valid target,
+    # rather than continue checking the full array (which will always fail to produce
+    # a valid next value), change the first position of the array being checked from 0
+    # to counter i and increase until you exhaust the prior possibilities. The fallback
+    # is that there will always be valid targets for previous sequence length = 1 aka
+    # from one column to the next.
+    
+    _ref_array = np.where(ref_array == -1, label_matrix, ref_array)
+    mask = np.all(label_matrix[:, i:ref_arr_len] == _ref_array[:, i:], axis=1)
+
+    valid_targets = np.unique(label_matrix[mask, arr_len])
+
+    if len(valid_targets) == 0:
+
+        i = i + 1
+        return process_row(
+            label_matrix, proba_lookup, lcd, rng, ref_array, acc_array, i)
+        
+    target_proba = np.array([proba_lookup[x] for x in valid_targets])
+
+    # typically, there will be more than 1 value in target_proba, but we have to guard against
+    # possibility of there being just one value, and if its probability is zero (Missing data)
+    # then summing it to 1 will result in NA (division by zero). As a workaround, set proba to
+    # 1 whenever it's the only possible value - since having it less than 1 doesn't make sense.
+    if len(target_proba) == 1:
+        target_proba = np.array([1])
+
+    # make sure the probabilities sum up to 1
+    target_proba = target_proba * (1 / sum(target_proba))
+
+    # take dispersion from the spec
+    dispersion = lcd[arr_len]
+
+    # default is to pick a random valid target
+    next_val = rng.choice(a=valid_targets, p=target_proba)
+
+    # except when it's already pre-generated
+    if ref_array[arr_len] != -1:
+        next_val = ref_array[arr_len]
+
+    # or dispersion is in effect; this part is expensive so only calculate if needed
+    elif dispersion and rng.random() < dispersion:
+        all_targets = np.unique(label_matrix[:, arr_len])
+        non_valid_targets = np.setdiff1d(all_targets, valid_targets)
+        if len(non_valid_targets) > 0:
+            next_val = rng.choice(a=non_valid_targets)
+
+    new_array = np.append(acc_array, next_val)
+
+    # update the ref_array to capture the just generated value
+    if ref_array[arr_len] == -1:
+        ref_array[arr_len] = next_val
+    
+    return process_row(label_matrix, proba_lookup, lcd, rng, ref_array, new_array)
+
+def build_new_lookups(spec_dict, linked_cols, original_lookup):
+    '''
+    Build two lookups: 
+        - from the numerical id to its aliased value. {0: 'hb_code__S08000015', ...}
+        - from the numerical id to the probability value {0: 0.5}
+         
+    Be mindful of all the intermediate steps. The intermediate lookup is created
+    with the numerical ID to a tuple and then split into two.
+
+    original_lookup is a positional to numerical_id, like so:
+        {'hb_code__0': 0} which is to say that the zero-th value in the list of
+        all hb_code values is aliased to the numerical id zero.
+
+    Special case if original values are not stored in the spec, but instead have
+    been put into the DB
+    '''
+
+    pos_labels_inc_column = []   # age__0, age__1, etc.
+    pos_label_to_orig_tuple = {} # age__0: (age__0-9, 0.5), etc.
+
+    for col in linked_cols:
+        
+        orig_vals = spec_dict["columns"][col]["original_values"]
+        prob_vector = None
+
+        if not isinstance(orig_vals, pd.DataFrame):
+
+            safe_col = col.replace(" ", "$")
+            table_id = spec_dict["metadata"]["id"]
+            orig_vals_db = query_exhibit_database(table_name=f"temp_{table_id}_{safe_col}")
+            orig_vals_sorted = (
+                sorted([x for x in orig_vals_db[col] if x != MISSING_DATA_STR]) + 
+                [MISSING_DATA_STR]
+            )
+
+            orig_vals = pd.DataFrame(data={col:orig_vals_sorted})
+
+            if "probability_vector" not in orig_vals_db.columns:
+                prob_vector = np.ones(orig_vals.shape[0])
+                prob_vector[-1] = spec_dict["columns"][col]["miss_probability"]
+            else:
+                prob_vector = orig_vals_db["probability_vector"].astype(float).values
+                prob_vector = np.append(
+                    prob_vector, spec_dict["columns"][col]["miss_probability"])          
+
+            prob_vector /= prob_vector.sum()
+
+        if prob_vector is None:
+            prob_vector = orig_vals["probability_vector"].values
+        
+        pos_labels_temp = [f"{col}__{x}" for x in range(len(orig_vals[col].values))]
+        pos_labels_inc_column.extend(pos_labels_temp)
+        pos_label_to_orig_tuple.update(
+            dict(zip(
+                pos_labels_temp, tuple(zip(orig_vals[col].values, prob_vector))
+            ))
+        )
+
+    # 0: age__0, etc. using the ORIGINAL lookup which has all the relationships
+    id_to_pos_label = {v:k for k, v in original_lookup.items()}
+
+    # if we don't check for the user removed values here, the next line
+    # will error out with an obscure Key not found message. 
+    if len(original_lookup) != len(pos_label_to_orig_tuple):
+        raise ValueError(textwrap.dedent("""
+        The number of values in user linked columns doesn't match original data.
+        If you would like to remove values, set their probability to zero.
+        """))
+
+    # 0: 'hb_code__aliased_code'
+    rev_labels = {k: pos_label_to_orig_tuple[v] for k, v in id_to_pos_label.items()}
+
+    # finally, split the tuple dictionary into two separate ones:
+    label_lookup = {k:v[0] for k, v in rev_labels.items()}
+    proba_lookup = {k:v[1] for k, v in rev_labels.items()}
+
+    return label_lookup, proba_lookup
diff --git a/exhibit/core/sql.py b/exhibit/core/sql.py
index 5a8d657..6ae8556 100644
--- a/exhibit/core/sql.py
+++ b/exhibit/core/sql.py
@@ -64,17 +64,16 @@ def query_exhibit_database(
     # define fully qualified table name, including schema if provided
     table_full_name = table_name if db_schema is None else ".".join([db_schema, table_name])
 
-    # create engine and connection
-    engine = create_engine(db_url)
-    conn = engine.connect()
-
     # column can come in as a string or as an empty list or as ["string"]
     if column and isinstance(column, list):
         column = column[0]
 
+    # create engine
+    engine = create_engine(db_url)
+
     # create metadata object of the DB, reflecting the required table
-    metadata = MetaData(bind=engine, schema=db_schema)
-    metadata.reflect(only=[table_name])
+    metadata = MetaData(schema=db_schema)
+    metadata.reflect(bind=engine, only=[table_name])
 
     # get the table class from metadata
     table = metadata.tables[table_full_name]
@@ -92,7 +91,8 @@ def query_exhibit_database(
         stmt = stmt.order_by(text(order))
 
     # get result object
-    result = conn.execute(stmt)
+    with engine.connect() as conn:
+        result = conn.execute(stmt)
 
     # build a Pandas dataframe
     column_names = [col[0] for col in result.cursor.description]
@@ -150,15 +150,8 @@ def create_temp_table(table_name, col_names, data, return_table=False, db_path=N
     db_schema = os.environ.get("EXHIBIT_DB_SCHEMA", None)
     db_path = db_path if db_path else package_dir(EXHIBIT_DB_LOCAL)
 
-    if db_url is None:
-        db_url = make_url("sqlite:///" + db_path + "?mode=rw")
-
-    # create engine and connection
-    engine = create_engine(db_url)
-    conn = engine.connect()
-
     # tables are created from a metadata object
-    metadata = MetaData(bind=engine, schema=db_schema)
+    metadata = MetaData(schema=db_schema)
 
     # to help with managing the data, convert tuples to a dataframe
     data_df = pd.DataFrame(data) if not isinstance(data, pd.DataFrame) else data
@@ -185,26 +178,38 @@ def create_temp_table(table_name, col_names, data, return_table=False, db_path=N
     # ensure the correct column data type for table creation
     table = Table(
         table_name, metadata, *[Column(c, t) for c, t in zip(col_names, data_types)])
+    
+    # connection block
+    if db_url is None:
+        db_url = make_url("sqlite:///" + db_path + "?mode=rw")
 
-    # drop the table from DB if it exists and then create
-    conn.execute(DropTable(table, if_exists=True))
-    metadata.create_all(engine)
-
-    # insert the values (assuming tuples in the data follow the col_names order)
-    # sqlite has a limit on how many records can be inserted into a table at one time
-    # see: https://www.sqlite.org/limits.html #9
-    chunk = 32_000
-    if (engine.dialect.name == "sqlite") and (num_records:=len(data)) > chunk: #pragma: no cover
-        for i, _ in enumerate(range(0, num_records, chunk)):
-            from_i = i * chunk
-            to_i = (i+1) * chunk
-            conn.execute(table.insert().values(data[from_i:to_i]))
-    else:
-        conn.execute(table.insert().values(data))
+    # create engine and connection
+    engine = create_engine(db_url)
 
-    # save the table in case it's required
-    if return_table:
-        result = conn.execute(select(table)).fetchall()
+    with engine.connect() as conn:
+
+        with conn.begin():
+
+            # drop the table from DB if it exists and then create
+            conn.execute(DropTable(table, if_exists=True))
+            metadata.create_all(engine)
+
+            # insert the values (assuming tuples in the data follow the col_names order)
+            # sqlite has a limit on how many records can be inserted into a table at one time
+            # see: https://www.sqlite.org/limits.html #9
+            chunk = 32_000
+            #pragma: no cover
+            if (engine.dialect.name == "sqlite") and (num_records:=len(data)) > chunk:
+                for i, _ in enumerate(range(0, num_records, chunk)):
+                    from_i = i * chunk
+                    to_i = (i+1) * chunk
+                    conn.execute(table.insert().values(data[from_i:to_i]))
+            else:
+                conn.execute(table.insert().values(data))
+
+            # save the table in case it's required
+            if return_table:
+                result = conn.execute(select(table)).fetchall()
 
     # shut down the engine which closes all associated connections
     engine.dispose()
@@ -247,17 +252,17 @@ def get_number_of_table_rows(table_name, column=None, db_path=None):
     table_full_name = table_name if db_schema is None else ".".join([db_schema, table_name])
 
     # create metadata object of the DB, reflecting the required table
-    metadata = MetaData(bind=engine, schema=db_schema)
-    metadata.reflect(only=[table_name])
+    metadata = MetaData(schema=db_schema)
+    metadata.reflect(bind=engine, only=[table_name])
 
     # get the table class from metadata
     table = metadata.tables[table_full_name]
 
     # either count distinct values of a given column, or just the size of the table
     if column:
-        stmt = select(func.count(table.c[column].distinct()))
+        stmt = select(func.count(table.c[column].distinct())).select_from(table) # pylint: disable=E1102
     else:
-        stmt = select([func.count()]).select_from(table)
+        stmt = select(func.count()).select_from(table) # pylint: disable=E1102
 
     # get the count
     result = conn.execute(stmt).fetchall()[0][0]
@@ -293,8 +298,8 @@ def get_number_of_table_columns(table_name, db_path=None):
     table_full_name = table_name if db_schema is None else ".".join([db_schema, table_name])
 
     # create metadata object of the DB, reflecting the required table
-    metadata = MetaData(bind=engine, schema=db_schema)
-    metadata.reflect(only=[table_name])
+    metadata = MetaData(schema=db_schema)
+    metadata.reflect(bind=engine, only=[table_name])
 
     # get the table class from metadata
     table = metadata.tables[table_full_name]
@@ -329,7 +334,14 @@ def check_table_exists(table_name, db_path=None):
 
 def execute_sql(sql, db_path=None):
     '''
-    Doc string
+    Connect to a database and execute SQL passed in as text. 
+
+    Parameters
+    ----------
+    sql : str
+        SQL query
+    db_path : str
+        Optional. Mainly used for testing when creating temporary database.
     '''
 
     db_url = os.environ.get("EXHIBIT_DB_URL", None)
@@ -342,6 +354,6 @@ def execute_sql(sql, db_path=None):
     engine = create_engine(db_url)
 
     with engine.connect() as conn:
-        result = conn.execute(sql).fetchall()
+        result = conn.execute(text(sql)).fetchall()
 
     return result
diff --git a/exhibit/core/utils.py b/exhibit/core/utils.py
index 95a7dae..5323cdc 100644
--- a/exhibit/core/utils.py
+++ b/exhibit/core/utils.py
@@ -208,10 +208,10 @@ def guess_date_frequency(timeseries):
 
     for period_range, period_alias in aliases.items():
         if first_period in period_range:
-            # decide whether it's period start (QS) or end (Q)
+            # decide whether it's period start or end (M/Q/YE)
             if period_alias in ["MS", "QS"]:
                 if not (timeseries.dt.day == 1).all():
-                    return period_alias[0]
+                    return period_alias[0] + "E"
             return period_alias
             
     return None #pragma: no cover
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index a97f1ee..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-python_version >= "3.10"
-dill == 0.3.7
-h3 == 3.7.6
-numpy == 1.25.2
-pandas == 2.0.3
-pyarrow == 14.0.1
-pyyaml == 6.0.1
-scipy == 1.11.1
-shapely == 2.0.1
-sql-metadata == 2.9.0
-sqlalchemy == 1.4.39
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index d85f81e..94befc5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,40 +1,42 @@
-[metadata]
-name = exhibit
-version = 0.9.8
-author = German Priks
-author_email = german.priks@pm.me
-description = Command line tool to generate anonymised demonstrator data
-long_description = file: README.md
-long_description_content_type = text/markdown
-url = https://github.com/gherka/exhibit
-classifiers =
-    Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.10
-    License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
-    Operating System :: OS Independent
-    Development Status :: 4 - Beta
-    Intended Audience :: Healthcare Industry
-    Intended Audience :: Science/Research
-    Topic :: Scientific/Engineering :: Information Analysis
-    Topic :: Security
-[options]
-packages = find:
-include_package_data = true
-python_requires = >=3.10
-install_requires =
-    dill == 0.3.7
-    h3 == 3.7.6
-    numpy == 1.25.2
-    pandas == 2.0.3
-    pyarrow == 14.0.1
-    pyyaml == 6.0.1
-    scipy == 1.11.1
-    shapely == 2.0.1
-    sql-metadata == 2.9.0
-    sqlalchemy == 1.4.39
-
-[options.entry_points]
-console_scripts =
-    exhibit = exhibit.command.bootstrap:main
-[options.extras_require]
-dev = memory_profiler
+[metadata]
+name = exhibit
+version = 0.9.9
+author = German Priks
+author_email = german.priks@pm.me
+description = Command line tool to generate anonymised demonstrator data
+long_description = file: README.md
+long_description_content_type = text/markdown
+url = https://github.com/gherka/exhibit
+classifiers =
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
+    Programming Language :: Python :: 3.12
+    License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
+    Operating System :: OS Independent
+    Development Status :: 4 - Beta
+    Intended Audience :: Healthcare Industry
+    Intended Audience :: Science/Research
+    Topic :: Scientific/Engineering :: Information Analysis
+    Topic :: Security
+[options]
+packages = find:
+include_package_data = true
+python_requires = >=3.10
+install_requires =
+    dill == 0.3.8
+    h3 == 3.7.7
+    numpy == 2.1.1
+    pandas == 2.2.2
+    pyarrow == 17.0.0
+    pyyaml == 6.0.2
+    scipy == 1.14.1
+    shapely == 2.0.6
+    sql-metadata == 2.13.0
+    sqlalchemy == 2.0.34
+
+[options.entry_points]
+console_scripts =
+    exhibit = exhibit.command.bootstrap:main
+[options.extras_require]
+dev = memory_profiler


Test patch:
diff --git a/.github/workflows/pr_tests_latest.yml b/.github/workflows/pr_tests_latest.yml
new file mode 100644
index 0000000..15c669b
--- /dev/null
+++ b/.github/workflows/pr_tests_latest.yml
@@ -0,0 +1,28 @@
+# This workflow will install Exhibit and its Python dependencies and run the tests
+
+name: Testing Pull Requests
+
+on:
+  pull_request:
+    branches: [ "latest" ]
+
+permissions:
+  contents: read
+
+jobs:
+  build:
+    name: Testing Pull Requests
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python 3.12.4
+      uses: actions/setup-python@v3
+      with:
+        python-version: "3.12.4"
+    - name: Install Exhibit and dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -e .
+    - name: Test with unittest
+      run: |
+        python -m unittest
diff --git a/exhibit/core/generate/tests/test_derived.py b/exhibit/core/generate/tests/test_derived.py
index a3be948..b41dec8 100644
--- a/exhibit/core/generate/tests/test_derived.py
+++ b/exhibit/core/generate/tests/test_derived.py
@@ -1,103 +1,103 @@
-'''
-Test the generation of continuous columns & values
-'''
-
-# Standard library imports
-import unittest
-
-# External library imports
-import pandas as pd
-from pandas.testing import assert_series_equal
-import numpy as np
-
-# Module under test
-from exhibit.core.generate import derived as tm
-
-class derivedTests(unittest.TestCase):
-    '''
-    Doc string
-    '''
-
-    def test_generate_derived_column_basic(self):
-        '''
-        All of the work is done by pandas.eval() method;
-        we're just testing column names with whitespace are OK
-        '''
-
-        test_df = pd.DataFrame(
-            data=np.ones((5, 2)),
-            columns=["Hello World", "A"])
-
-        calc = "Hello World + A"
-
-        self.assertEqual(tm.generate_derived_column(test_df, calc).sum(), 10)
-
-    def test_generate_derived_column_groupby(self):
-        '''
-        We want to allow users to create aggregated columns, like peer values.
-        Make sure that column names are enclosed in single spaces.
-        '''
-
-        test_df = pd.DataFrame(
-            data={
-                "C1":["A", "A", "B", "B", "C", "C"], #locations
-                "C2":["spam", "eggs"] * 3, #groupby dimension(s)
-                "C3":[1, 10] * 3 #aggregation column
-            }
-        )
-
-        calc = "df.groupby('C2')['C3'].sum()"
-
-        expected = pd.Series([3, 30, 3, 30, 3, 30], name="C3")
-
-        assert_series_equal(
-            left=tm.generate_derived_column(test_df, calc),
-            right=expected,
-            check_dtype=False
-            )
-        
-    def test_generate_derived_column_with_timestamp(self):
-        '''
-        We want to allow users to create timestamps using generated columns with
-        hours, minutes and seconds. Bear in mind that missing values in all column
-        types are represented as np.nan.
-        '''
-
-        dates = pd.date_range(
-            start="01-01-2022",
-            periods=3,
-            freq="M",            
-        )
-
-        test_df = pd.DataFrame(
-            data={
-                "dates"  : dates,
-                "hours"  : pd.Categorical(["1", "2", np.nan]),
-                "minutes": [0, np.nan, 59],
-                "seconds": [0, 1, 10],
-            }
-        )
-
-        calc = "@create_timestamp(hours, minutes, seconds)"
-
-        expected = pd.Series([
-            "2022-01-31 01:00:00",
-            "2022-02-28 02:00:01",
-            "2022-03-31 00:59:10"
-        ])
-
-        # can add dates and timedelta timestamps easily
-        result = (
-            test_df["dates"] + tm.generate_derived_column(test_df, calc)
-        ).astype(str)
- 
-        assert_series_equal(
-            left=result,
-            right=expected,
-            check_dtype=False
-        )
-
-if __name__ == "__main__" and __package__ is None:
-    #overwrite __package__ builtin as per PEP 366
-    __package__ = "exhibit"
-    unittest.main(warnings="ignore")
+'''
+Test the generation of continuous columns & values
+'''
+
+# Standard library imports
+import unittest
+
+# External library imports
+import pandas as pd
+from pandas.testing import assert_series_equal
+import numpy as np
+
+# Module under test
+from exhibit.core.generate import derived as tm
+
+class derivedTests(unittest.TestCase):
+    '''
+    Doc string
+    '''
+
+    def test_generate_derived_column_basic(self):
+        '''
+        All of the work is done by pandas.eval() method;
+        we're just testing column names with whitespace are OK
+        '''
+
+        test_df = pd.DataFrame(
+            data=np.ones((5, 2)),
+            columns=["Hello World", "A"])
+
+        calc = "Hello World + A"
+
+        self.assertEqual(tm.generate_derived_column(test_df, calc).sum(), 10)
+
+    def test_generate_derived_column_groupby(self):
+        '''
+        We want to allow users to create aggregated columns, like peer values.
+        Make sure that column names are enclosed in single spaces.
+        '''
+
+        test_df = pd.DataFrame(
+            data={
+                "C1":["A", "A", "B", "B", "C", "C"], #locations
+                "C2":["spam", "eggs"] * 3, #groupby dimension(s)
+                "C3":[1, 10] * 3 #aggregation column
+            }
+        )
+
+        calc = "df.groupby('C2')['C3'].sum()"
+
+        expected = pd.Series([3, 30, 3, 30, 3, 30], name="C3")
+
+        assert_series_equal(
+            left=tm.generate_derived_column(test_df, calc),
+            right=expected,
+            check_dtype=False
+            )
+        
+    def test_generate_derived_column_with_timestamp(self):
+        '''
+        We want to allow users to create timestamps using generated columns with
+        hours, minutes and seconds. Bear in mind that missing values in all column
+        types are represented as np.nan.
+        '''
+
+        dates = pd.date_range(
+            start="01-01-2022",
+            periods=3,
+            freq="ME",            
+        )
+
+        test_df = pd.DataFrame(
+            data={
+                "dates"  : dates,
+                "hours"  : pd.Categorical(["1", "2", np.nan]),
+                "minutes": [0, np.nan, 59],
+                "seconds": [0, 1, 10],
+            }
+        )
+
+        calc = "@create_timestamp(hours, minutes, seconds)"
+
+        expected = pd.Series([
+            "2022-01-31 01:00:00",
+            "2022-02-28 02:00:01",
+            "2022-03-31 00:59:10"
+        ])
+
+        # can add dates and timedelta timestamps easily
+        result = (
+            test_df["dates"] + tm.generate_derived_column(test_df, calc)
+        ).astype(str)
+ 
+        assert_series_equal(
+            left=result,
+            right=expected,
+            check_dtype=False
+        )
+
+if __name__ == "__main__" and __package__ is None:
+    #overwrite __package__ builtin as per PEP 366
+    __package__ = "exhibit"
+    unittest.main(warnings="ignore")
diff --git a/exhibit/core/generate/tests/test_missing.py b/exhibit/core/generate/tests/test_missing.py
index 002b3aa..10b4262 100644
--- a/exhibit/core/generate/tests/test_missing.py
+++ b/exhibit/core/generate/tests/test_missing.py
@@ -1,618 +1,618 @@
-'''
-Test the handling & generation of missing values
-'''
-
-# Standard library imports
-import unittest
-from collections import namedtuple
-from unittest.mock import Mock, patch
-
-# External library imports
-import pandas as pd
-import numpy as np
-from pandas.testing import assert_frame_equal, assert_series_equal
-
-# Exhibit imports
-from exhibit.db import db_util
-from exhibit.core.constants import MISSING_DATA_STR, ORIGINAL_VALUES_PAIRED
-from exhibit.core.tests.test_reference import temp_exhibit
-
-# Module under test
-from exhibit.core.generate import missing as tm
-
-class missingDataTests(unittest.TestCase):
-    '''
-    Doc string
-    '''
-
-    @classmethod
-    def tearDownClass(cls):
-        '''
-        Clean up local exhibit.db from temp tables
-        '''
-
-        db_util.purge_temp_tables()
-
-    def test_feeding_data_to_missing_generator(self):
-        '''
-        Doc string
-        '''
-
-        test_df = pd.DataFrame()
-
-        path = "exhibit.core.generate.missing.MissingDataGenerator.__init__"
-        with patch(path) as mock_init:
-            mock_init.return_value = None
-            generatorMock = tm.MissingDataGenerator(Mock(), Mock())
-
-        setattr(generatorMock, "data", test_df)
-
-        self.assertTrue(
-            isinstance(generatorMock.data,
-            pd.DataFrame))
-
-    def test_never_null_indices_are_identified(self):
-        '''
-        Some cells can't ever have nulls due to custom constraints.
-        Filter and Partition fields are optional when defining custom
-        constraints.
-        '''
-
-        test_dict = {
-            "constraints" : {
-                "custom_constraints": {
-                    "cc1" : {
-                        "filter"  : "~Test.isna()",
-                        "targets" : {
-                            "Num" : "make_not_null"
-                        }
-                    },
-                }
-            }
-        }
-
-        test_data = pd.DataFrame(data={
-            "Test" : [1, 2, 3, pd.NA, 5],
-            "Num"  : [1, 2, 3, 4, 5]
-        })
-
-        test_gen = tm.MissingDataGenerator(test_dict, test_data)
-
-        not_null_idx = test_gen._find_not_null_idx()
-
-        result = not_null_idx[0]
-
-        assert_series_equal(
-            test_data.loc[result],
-            test_data.loc[[0, 1, 2, 4], "Num"])
-
-    def test_paired_columns_with_missing_data_identified(self):
-        '''
-        Doc string
-        '''
-
-        test_dict = {
-            "columns" : {
-                "A" : {
-                    "type"            : "categorical",
-                    "paired_columns"  : ["B"],
-                    "miss_probability": 0.5,
-                    "original_values" : ORIGINAL_VALUES_PAIRED
-                },
-                "B" : {
-                    "type"            : "categorical",
-                    "paired_columns" : ["A"],
-                    "miss_probability" : 0.5,
-                    "original_values" : pd.DataFrame()
-                },
-                "C" : {
-                    "type"            : "categorical",
-                    "paired_columns" : ["D"],
-                    "miss_probability" : 0.6,
-                    "original_values" : pd.DataFrame()
-                },
-                "D" : {
-                    "type"            : "categorical",
-                    "paired_columns" : ["C"],
-                    "miss_probability" : 0.7,
-                    "original_values" : ORIGINAL_VALUES_PAIRED
-                }
-            },
-            "constraints" : {
-                "custom_constraints" : {},
-                
-                },
-            "linked_columns" : []
-        }
-
-        expected = [
-            {"A", "B"},
-        ]
-
-        test_gen = tm.MissingDataGenerator(test_dict, Mock())
-        result = test_gen._find_columns_with_linked_missing_data()
-
-        self.assertCountEqual(expected, result) 
-
-    def test_linked_columns_with_missing_data_identified(self):
-        '''
-        Doc string
-        '''
-
-        test_dict = {
-            "columns" : {
-                "A" : {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0.5,
-                    "original_values" : pd.DataFrame()
-                    },
-                "B" : {
-                    "type"            : "categorical",
-                    "paired_columns" : [],
-                    "miss_probability" : 0.5,
-                    "original_values" : pd.DataFrame()
-                    },
-                "C" : {
-                    "type"            : "categorical",
-                    "paired_columns" : [],
-                    "miss_probability" : 0.6,
-                    "original_values" : pd.DataFrame()
-                    },
-                "D" : {
-                    "type"            : "categorical",
-                    "paired_columns" : [],
-                    "miss_probability" : 0.5,
-                    "original_values" : pd.DataFrame()
-                    }
-            },
-            "constraints" : {
-                "custom_constraints" : {},
-                },
-            "linked_columns" : [
-                (1, ["A", "B"]),
-                (2, ["C", "D"])
-                ]
-        }
-
-        expected = [
-            {"A", "B"},
-        ]
-
-        test_gen = tm.MissingDataGenerator(test_dict, Mock())
-        result = test_gen._find_columns_with_linked_missing_data()
-
-        self.assertCountEqual(expected, result)     
-
-    def test_linked_and_paired_columns_with_missing_data_identified(self):
-        '''
-        Doc string
-        '''
-
-        test_dict = {
-            "columns" : {
-                "A" : {
-                    "type"            : "categorical",
-                    "paired_columns"  : ["B"],
-                    "miss_probability": 0.5,
-                    "original_values" : pd.DataFrame()
-                    },
-                "B" : {
-                    "type"            : "categorical",
-                    "paired_columns" : ["A"],
-                    "miss_probability" : 0.5,
-                    "original_values" : ORIGINAL_VALUES_PAIRED
-                    },
-                "C" : {
-                    "type"            : "categorical",
-                    "paired_columns" : [],
-                    "miss_probability" : 0.5,
-                    "original_values" : pd.DataFrame()
-                    }
-            },
-            "constraints" : {
-                "custom_constraints" : {},
-                },
-            "linked_columns" : [
-                (0, ["A", "C"]),
-                ]
-        }
-
-        expected = [
-            {"A", "B", "C"},
-        ]
-
-        test_gen = tm.MissingDataGenerator(test_dict, Mock())
-        result = test_gen._find_columns_with_linked_missing_data()
-
-        self.assertTrue(expected[0], result[0])      
-        
-    def test_make_null_constraint_in_columns(self):
-        '''
-        When we're adding nulls to categorical columns, the non-null 
-        numerical values must be re-calulcated and re-scaled because
-        Missing data (null identifier in categorical columns) can have
-        vastly different weights compared to the old values. However,
-        we shouldn't rescaled the whole column anew, just the added values.
-        '''
-
-        Weights = namedtuple("Weights", ["weight", "equal_weight"])
-
-        #demo weights table
-        weights_df = pd.DataFrame(
-            data=[
-                ("C", "A", "spam", Weights(0.5, 0.5)),
-                ("C", "A", "eggs", Weights(0.5, 0.5)),
-                ("C", "B", "bacon", Weights(0.5, 0.5)),
-                ("C", "A", MISSING_DATA_STR, Weights(0.5, 0.5)),
-                ("C", "B", MISSING_DATA_STR, Weights(0.5, 0.5)),
-            ],
-            columns=["num_col", "cat_col", "cat_value", "weights"])
-
-        #reformat into dictionary
-        weights = (
-            weights_df
-                .set_index(["num_col", "cat_col", "cat_value"])
-                .to_dict(orient="index")
-        )
-
-        test_dict = {
-            "_rng" : np.random.default_rng(seed=0),
-            "metadata" : {
-                "categorical_columns": ["A", "B"],
-                "numerical_columns" : ["C"]
-            },
-            "columns": {
-                "A": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0,
-                    "original_values" : pd.DataFrame()
-                    
-                },
-                "B": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0,
-                    "original_values" : pd.DataFrame()
-
-                },
-                "C": {
-                    "type"            : "continuous",
-                    "precision"       : "integer",
-                    "distribution"    : "weighted_uniform",
-                    "distribution_parameters": {
-                        "dispersion": 0,
-                        "target_sum" : 10,
-                    },
-                    "miss_probability": 0
-                },
-
-            },
-            "constraints" : {
-                "custom_constraints": {
-                    "cc1" : {
-                        "filter"  : "A == 'spam'",
-                        "targets" : {
-                            "B" : "make_null"
-                        }
-                    }
-                }
-            },
-            "linked_columns" : [],
-            "weights_table" : weights,
-            "weights_table_target_cols": ["A", "B"]
-        }
-
-        test_data = pd.DataFrame(data={
-            "A" : ["spam", "spam", "eggs", "eggs"],
-            "B" : ["bacon"] * 4,
-            "C" : [10, 20, 4, 4],
-        })
-
-        expected = pd.DataFrame(data={
-            "A" : ["spam", "spam", "eggs", "eggs"],
-            "B" : [pd.NA, pd.NA, "bacon", "bacon"],
-            "C" : [1, 1, 4, 4],
-        })
-
-        test_gen = tm.MissingDataGenerator(test_dict, test_data)
-        result = test_gen.add_missing_data()
-
-        assert_frame_equal(result, expected, check_dtype=False)
-
-    def test_not_null_constraint_in_columns(self):
-        '''
-        Doc string
-        '''
-
-        test_dict = {
-            "_rng" : np.random.default_rng(seed=0),
-            "metadata" : {
-                "categorical_columns": ["C", "D"],
-                "numerical_columns" : []
-            },
-            "columns": {
-                "C": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0.2,
-                    "original_values" : pd.DataFrame()
-                    
-                },
-                "D": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0.5,
-                    "original_values" : pd.DataFrame()
-
-                }
-            },
-            "constraints" : {
-                "custom_constraints": {
-                    "cc1" : {
-                        "filter"  : "~C.isna()",
-                        "targets" : {
-                            "D" : "make_not_null"
-                        }
-                    }
-                }
-            },
-            "linked_columns" : []
-        }
-
-        test_data = pd.DataFrame(data={
-            "C" : np.random.random(1000), #pylint: disable=no-member
-            "D" : np.random.random(1000), #pylint: disable=no-member
-
-        })
-
-        test_gen = tm.MissingDataGenerator(test_dict, test_data)
-        result = test_gen.add_missing_data()
-
-        self.assertTrue(result["C"].isna().any())
-        self.assertTrue(result["D"].isna().any())
-        self.assertFalse(result.loc[~result["C"].isna(), "D"].isna().any())
-
-    def test_paired_columns_are_respected_for_missing_data(self):
-        '''
-        Doc string
-        '''
-
-        test_dict = {
-            "_rng" : np.random.default_rng(seed=0),
-            "metadata" : {
-                "categorical_columns": ["A", "B"],
-                "numerical_columns" : []
-            },
-            "columns": {
-                "A": {
-                    "type"            : "categorical",
-                    "paired_columns"  : ["B"],
-                    "miss_probability": 0.5,
-                    "original_values" : pd.DataFrame()
-                },
-                "B": {
-                    "type"            : "categorical",
-                    "paired_columns"  : ["A"],
-                    "miss_probability": 0.5,
-                    "original_values" : ORIGINAL_VALUES_PAIRED
-
-                },
-            },
-            "constraints" : {
-                "custom_constraints" : {},
-            },
-            "linked_columns" : [],
-        }
-
-        test_data = pd.DataFrame(data={
-            "A" : np.random.random(1000), #pylint: disable=no-member
-            "B" : np.random.random(1000), #pylint: disable=no-member
-        })
-
-        test_gen = tm.MissingDataGenerator(test_dict, test_data)
-        result = test_gen.add_missing_data()
-
-        self.assertTrue(result["A"].isna().any())
-        self.assertTrue(result["B"].isna().any())
-        assert_series_equal(result["B"].isna(), result["A"].isna(), check_names=False)
-
-    def test_missing_data_added_to_standalone_categorical_column(self):
-        '''
-        Doc string
-        '''
-
-        test_dict = {
-            "_rng" : np.random.default_rng(seed=0),
-            "metadata" : {
-                "categorical_columns": ["A", "B"],
-                "numerical_columns" : []
-            },
-            "columns": {
-                "A": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 1,
-                    "original_values" : pd.DataFrame()
-                },
-                "B": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0,
-                    "original_values" : pd.DataFrame()
-
-                },
-            },
-            "constraints" : {
-                "custom_constraints" : {}
-            },
-            "linked_columns" : [],
-        }
-
-        test_data = pd.DataFrame(data={
-            "A" : list("ABCDE"),
-            "B" : list("ABCDE")
-        })
-
-        expected = pd.DataFrame(data={
-            "A" : [pd.NA] * 5,
-            "B" : list("ABCDE")
-        })
-
-        test_gen = tm.MissingDataGenerator(test_dict, test_data)
-        result = test_gen.add_missing_data()
-
-        assert_frame_equal(expected, result, check_dtype=False)
-
-    def test_continuous_column_adjusted_to_categorical_missing_data(self):
-        '''
-        Remember that continuous columns depend on values in categorical columns
-        in the same row for their weights, including for Missing data values.
-        Adding Missing data also changes the target_sum of the continuous column
-        so we need to re-scale the whole column after adding missing data either
-        to it or to the categorical columns.
-        
-        We rely on np.random to generate reasonable number of NAs with 0.5 prob,
-        but that can sometimes fail so we ensure that the seed is constant.
-        '''
-
-        Weights = namedtuple("Weights", ["weight", "equal_weight"])
-
-        #demo weights table
-        weights_df = pd.DataFrame(
-            data=[
-                ("C2", "C1", "A", Weights(0.1, 0.5)),
-                ("C2", "C1", "B", Weights(0.9, 0.5)),
-                ("C2", "C1", MISSING_DATA_STR, Weights(0.2, 0.5)),
-            ],
-            columns=["num_col", "cat_col", "cat_value", "weights"])
-
-        #reformat into dictionary
-        weights = (
-            weights_df
-                .set_index(["num_col", "cat_col", "cat_value"])
-                .to_dict(orient="index")
-        )
-
-        test_dict = {
-            "_rng" : np.random.default_rng(seed=0),
-            "metadata": {
-                "categorical_columns": [
-                    "C1"
-                ],
-                "numerical_columns" : [
-                    "C2"
-                ]
-            },
-            "columns": {
-                "C1": {
-                    "type"            : "categorical",
-                    "paired_columns"  : [],
-                    "miss_probability": 0.5,
-                    "original_values" : pd.DataFrame()
-                },
-                "C2": {
-                    "type"            : "continuous",
-                    "precision"       : "integer",
-                    "distribution"    : "weighted_uniform",
-                    "distribution_parameters": {
-                        "uniform_base_value" : 100,
-                        "dispersion": 0,
-                        "target_sum" : 200, # factor of two
-                    },
-                    "miss_probability": 0
-                },
-            },
-            "constraints" : {
-                "custom_constraints" : {}
-            },
-            "linked_columns" : [],
-            "weights_table" : weights,
-            "weights_table_target_cols": ["C1"]
-        }
-
-        test_data = pd.DataFrame(data={
-            "C1" : ["A", "A", "A", "B", "B"] * 20,
-            "C2" : [1] * 100
-        })
-
-
-        test_gen = tm.MissingDataGenerator(test_dict, test_data)
-        result = test_gen.add_missing_data()
-
-        self.assertTrue(result["C1"].isna().any())
-        self.assertEqual(result["C2"].sum(), 200)
-
-    def test_user_linked_columns_having_missing_data(self):
-        '''
-        Because user linked columns can have complex relationships, we 
-        need to make sure missing data is handled correctly.
-        '''
-
-        test_df = pd.DataFrame(data={
-            "A": ["spam", "spam", "eggs", "eggs", "spam"],
-            "B": ["bacon", "spamspam", np.nan, "parrot", "bacon"],
-            "C": range(5)
-        })
-
-        test_dict = {
-            "metadata" : {
-                "number_of_rows" : 1000
-            }
-        }
-
-        fromdata_test = {
-            "linked_columns" : ["A", "B"]
-        }
-
-        _, df = temp_exhibit(
-            filename=test_df, fromdata_namespace=fromdata_test,
-            test_spec_dict=test_dict, return_spec=False)
-
-        self.assertTrue(df.query("A == 'eggs'")["B"].isna().any())    
-
-    def test_categorical_numerical_missing_data_with_make_null_cc(self):
-        '''
-        Typing issues (categorical vs object) can cause bugs when we have categorical columns,
-        a make_null custom constraint, a filter casting categorical column to integers (which
-        assumes object, not categorical - because you can't cast categorical to int if there 
-        is a Missing data categorical value - without removing unused categories first) AND
-        a numerical column. Commenting out the numerical column used to pass the test, and
-        uncommenting it used to fail it - which is wrong.
-
-        Without extra checks, AGE.astype('int') will fail if AGE is dtype="category" because
-        it'll have numbers as strings (which can be cast to int) and "invisible" Missing data
-        which can't.
-        '''
-
-        test_df = pd.DataFrame(data={
-            "AGE": ["1", "2", "3", "4", "4"],
-            "NULLED" : list("ABCAB"),
-            "NUMS": range(5)
-        })
-
-        test_dict = {
-            "metadata" : {
-                "number_of_rows" : 10,
-                "categorical_columns": ["AGE", "NULLED"],
-                "numerical_columns" : ["NUMS"]
-            },
-            "constraints" : {
-                "custom_constraints" : {
-                    "test_nulls" : {
-                        "filter" : "AGE.astype('int') > 1",
-                        "targets" : {"NULLED" : "make_null"}
-                    }
-                }
-            }
-        }
-
-        _, df = temp_exhibit(filename=test_df, test_spec_dict=test_dict, return_spec=False)
-
-        self.assertTrue(df.NULLED.isna().any())    
-        
-if __name__ == "__main__" and __package__ is None:
-    #overwrite __package__ builtin as per PEP 366
-    __package__ = "exhibit"
-    unittest.main(warnings="ignore")
+'''
+Test the handling & generation of missing values
+'''
+
+# Standard library imports
+import unittest
+from collections import namedtuple
+from unittest.mock import Mock, patch
+
+# External library imports
+import pandas as pd
+import numpy as np
+from pandas.testing import assert_frame_equal, assert_series_equal
+
+# Exhibit imports
+from exhibit.db import db_util
+from exhibit.core.constants import MISSING_DATA_STR, ORIGINAL_VALUES_PAIRED
+from exhibit.core.tests.test_reference import temp_exhibit
+
+# Module under test
+from exhibit.core.generate import missing as tm
+
+class missingDataTests(unittest.TestCase):
+    '''
+    Doc string
+    '''
+
+    @classmethod
+    def tearDownClass(cls):
+        '''
+        Clean up local exhibit.db from temp tables
+        '''
+
+        db_util.purge_temp_tables()
+
+    def test_feeding_data_to_missing_generator(self):
+        '''
+        Doc string
+        '''
+
+        test_df = pd.DataFrame()
+
+        path = "exhibit.core.generate.missing.MissingDataGenerator.__init__"
+        with patch(path) as mock_init:
+            mock_init.return_value = None
+            generatorMock = tm.MissingDataGenerator(Mock(), Mock())
+
+        setattr(generatorMock, "data", test_df)
+
+        self.assertTrue(
+            isinstance(generatorMock.data,
+            pd.DataFrame))
+
+    def test_never_null_indices_are_identified(self):
+        '''
+        Some cells can't ever have nulls due to custom constraints.
+        Filter and Partition fields are optional when defining custom
+        constraints.
+        '''
+
+        test_dict = {
+            "constraints" : {
+                "custom_constraints": {
+                    "cc1" : {
+                        "filter"  : "~Test.isna()",
+                        "targets" : {
+                            "Num" : "make_not_null"
+                        }
+                    },
+                }
+            }
+        }
+
+        test_data = pd.DataFrame(data={
+            "Test" : [1, 2, 3, pd.NA, 5],
+            "Num"  : [1, 2, 3, 4, 5]
+        })
+
+        test_gen = tm.MissingDataGenerator(test_dict, test_data)
+
+        not_null_idx = test_gen._find_not_null_idx()
+
+        result = not_null_idx[0]
+
+        assert_series_equal(
+            test_data.loc[result],
+            test_data.loc[[0, 1, 2, 4], "Num"])
+
+    def test_paired_columns_with_missing_data_identified(self):
+        '''
+        Doc string
+        '''
+
+        test_dict = {
+            "columns" : {
+                "A" : {
+                    "type"            : "categorical",
+                    "paired_columns"  : ["B"],
+                    "miss_probability": 0.5,
+                    "original_values" : ORIGINAL_VALUES_PAIRED
+                },
+                "B" : {
+                    "type"            : "categorical",
+                    "paired_columns" : ["A"],
+                    "miss_probability" : 0.5,
+                    "original_values" : pd.DataFrame()
+                },
+                "C" : {
+                    "type"            : "categorical",
+                    "paired_columns" : ["D"],
+                    "miss_probability" : 0.6,
+                    "original_values" : pd.DataFrame()
+                },
+                "D" : {
+                    "type"            : "categorical",
+                    "paired_columns" : ["C"],
+                    "miss_probability" : 0.7,
+                    "original_values" : ORIGINAL_VALUES_PAIRED
+                }
+            },
+            "constraints" : {
+                "custom_constraints" : {},
+                
+                },
+            "linked_columns" : []
+        }
+
+        expected = [
+            {"A", "B"},
+        ]
+
+        test_gen = tm.MissingDataGenerator(test_dict, Mock())
+        result = test_gen._find_columns_with_linked_missing_data()
+
+        self.assertCountEqual(expected, result) 
+
+    def test_linked_columns_with_missing_data_identified(self):
+        '''
+        Doc string
+        '''
+
+        test_dict = {
+            "columns" : {
+                "A" : {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0.5,
+                    "original_values" : pd.DataFrame()
+                    },
+                "B" : {
+                    "type"            : "categorical",
+                    "paired_columns" : [],
+                    "miss_probability" : 0.5,
+                    "original_values" : pd.DataFrame()
+                    },
+                "C" : {
+                    "type"            : "categorical",
+                    "paired_columns" : [],
+                    "miss_probability" : 0.6,
+                    "original_values" : pd.DataFrame()
+                    },
+                "D" : {
+                    "type"            : "categorical",
+                    "paired_columns" : [],
+                    "miss_probability" : 0.5,
+                    "original_values" : pd.DataFrame()
+                    }
+            },
+            "constraints" : {
+                "custom_constraints" : {},
+                },
+            "linked_columns" : [
+                (1, ["A", "B"]),
+                (2, ["C", "D"])
+                ]
+        }
+
+        expected = [
+            {"A", "B"},
+        ]
+
+        test_gen = tm.MissingDataGenerator(test_dict, Mock())
+        result = test_gen._find_columns_with_linked_missing_data()
+
+        self.assertCountEqual(expected, result)     
+
+    def test_linked_and_paired_columns_with_missing_data_identified(self):
+        '''
+        Doc string
+        '''
+
+        test_dict = {
+            "columns" : {
+                "A" : {
+                    "type"            : "categorical",
+                    "paired_columns"  : ["B"],
+                    "miss_probability": 0.5,
+                    "original_values" : pd.DataFrame()
+                    },
+                "B" : {
+                    "type"            : "categorical",
+                    "paired_columns" : ["A"],
+                    "miss_probability" : 0.5,
+                    "original_values" : ORIGINAL_VALUES_PAIRED
+                    },
+                "C" : {
+                    "type"            : "categorical",
+                    "paired_columns" : [],
+                    "miss_probability" : 0.5,
+                    "original_values" : pd.DataFrame()
+                    }
+            },
+            "constraints" : {
+                "custom_constraints" : {},
+                },
+            "linked_columns" : [
+                (0, ["A", "C"]),
+                ]
+        }
+
+        expected = [
+            {"A", "B", "C"},
+        ]
+
+        test_gen = tm.MissingDataGenerator(test_dict, Mock())
+        result = test_gen._find_columns_with_linked_missing_data()
+
+        self.assertTrue(expected[0], result[0])      
+        
+    def test_make_null_constraint_in_columns(self):
+        '''
+        When we're adding nulls to categorical columns, the non-null 
+        numerical values must be re-calulcated and re-scaled because
+        Missing data (null identifier in categorical columns) can have
+        vastly different weights compared to the old values. However,
+        we shouldn't rescaled the whole column anew, just the added values.
+        '''
+
+        Weights = namedtuple("Weights", ["weight", "equal_weight"])
+
+        #demo weights table
+        weights_df = pd.DataFrame(
+            data=[
+                ("C", "A", "spam", Weights(0.5, 0.5)),
+                ("C", "A", "eggs", Weights(0.5, 0.5)),
+                ("C", "B", "bacon", Weights(0.5, 0.5)),
+                ("C", "A", MISSING_DATA_STR, Weights(0.5, 0.5)),
+                ("C", "B", MISSING_DATA_STR, Weights(0.5, 0.5)),
+            ],
+            columns=["num_col", "cat_col", "cat_value", "weights"])
+
+        #reformat into dictionary
+        weights = (
+            weights_df
+                .set_index(["num_col", "cat_col", "cat_value"])
+                .to_dict(orient="index")
+        )
+
+        test_dict = {
+            "_rng" : np.random.default_rng(seed=0),
+            "metadata" : {
+                "categorical_columns": ["A", "B"],
+                "numerical_columns" : ["C"]
+            },
+            "columns": {
+                "A": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0,
+                    "original_values" : pd.DataFrame()
+                    
+                },
+                "B": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0,
+                    "original_values" : pd.DataFrame()
+
+                },
+                "C": {
+                    "type"            : "continuous",
+                    "precision"       : "integer",
+                    "distribution"    : "weighted_uniform",
+                    "distribution_parameters": {
+                        "dispersion": 0,
+                        "target_sum" : 10,
+                    },
+                    "miss_probability": 0
+                },
+
+            },
+            "constraints" : {
+                "custom_constraints": {
+                    "cc1" : {
+                        "filter"  : "A == 'spam'",
+                        "targets" : {
+                            "B" : "make_null"
+                        }
+                    }
+                }
+            },
+            "linked_columns" : [],
+            "weights_table" : weights,
+            "weights_table_target_cols": ["A", "B"]
+        }
+
+        test_data = pd.DataFrame(data={
+            "A" : ["spam", "spam", "eggs", "eggs"],
+            "B" : ["bacon"] * 4,
+            "C" : [10, 20, 4, 4],
+        })
+
+        expected = pd.DataFrame(data={
+            "A" : ["spam", "spam", "eggs", "eggs"],
+            "B" : [np.nan, np.nan, "bacon", "bacon"],
+            "C" : [1, 1, 4, 4],
+        })
+
+        test_gen = tm.MissingDataGenerator(test_dict, test_data)
+        result = test_gen.add_missing_data()
+
+        assert_frame_equal(result, expected, check_dtype=False)
+
+    def test_not_null_constraint_in_columns(self):
+        '''
+        Doc string
+        '''
+
+        test_dict = {
+            "_rng" : np.random.default_rng(seed=0),
+            "metadata" : {
+                "categorical_columns": ["C", "D"],
+                "numerical_columns" : []
+            },
+            "columns": {
+                "C": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0.2,
+                    "original_values" : pd.DataFrame()
+                    
+                },
+                "D": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0.5,
+                    "original_values" : pd.DataFrame()
+
+                }
+            },
+            "constraints" : {
+                "custom_constraints": {
+                    "cc1" : {
+                        "filter"  : "~C.isna()",
+                        "targets" : {
+                            "D" : "make_not_null"
+                        }
+                    }
+                }
+            },
+            "linked_columns" : []
+        }
+
+        test_data = pd.DataFrame(data={
+            "C" : np.random.random(1000), #pylint: disable=no-member
+            "D" : np.random.random(1000), #pylint: disable=no-member
+
+        })
+
+        test_gen = tm.MissingDataGenerator(test_dict, test_data)
+        result = test_gen.add_missing_data()
+
+        self.assertTrue(result["C"].isna().any())
+        self.assertTrue(result["D"].isna().any())
+        self.assertFalse(result.loc[~result["C"].isna(), "D"].isna().any())
+
+    def test_paired_columns_are_respected_for_missing_data(self):
+        '''
+        Doc string
+        '''
+
+        test_dict = {
+            "_rng" : np.random.default_rng(seed=0),
+            "metadata" : {
+                "categorical_columns": ["A", "B"],
+                "numerical_columns" : []
+            },
+            "columns": {
+                "A": {
+                    "type"            : "categorical",
+                    "paired_columns"  : ["B"],
+                    "miss_probability": 0.5,
+                    "original_values" : pd.DataFrame()
+                },
+                "B": {
+                    "type"            : "categorical",
+                    "paired_columns"  : ["A"],
+                    "miss_probability": 0.5,
+                    "original_values" : ORIGINAL_VALUES_PAIRED
+
+                },
+            },
+            "constraints" : {
+                "custom_constraints" : {},
+            },
+            "linked_columns" : [],
+        }
+
+        test_data = pd.DataFrame(data={
+            "A" : np.random.random(1000), #pylint: disable=no-member
+            "B" : np.random.random(1000), #pylint: disable=no-member
+        })
+
+        test_gen = tm.MissingDataGenerator(test_dict, test_data)
+        result = test_gen.add_missing_data()
+
+        self.assertTrue(result["A"].isna().any())
+        self.assertTrue(result["B"].isna().any())
+        assert_series_equal(result["B"].isna(), result["A"].isna(), check_names=False)
+
+    def test_missing_data_added_to_standalone_categorical_column(self):
+        '''
+        Doc string
+        '''
+
+        test_dict = {
+            "_rng" : np.random.default_rng(seed=0),
+            "metadata" : {
+                "categorical_columns": ["A", "B"],
+                "numerical_columns" : []
+            },
+            "columns": {
+                "A": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 1,
+                    "original_values" : pd.DataFrame()
+                },
+                "B": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0,
+                    "original_values" : pd.DataFrame()
+
+                },
+            },
+            "constraints" : {
+                "custom_constraints" : {}
+            },
+            "linked_columns" : [],
+        }
+
+        test_data = pd.DataFrame(data={
+            "A" : list("ABCDE"),
+            "B" : list("ABCDE")
+        })
+
+        expected = pd.DataFrame(data={
+            "A" : [np.nan] * 5,
+            "B" : list("ABCDE")
+        })
+
+        test_gen = tm.MissingDataGenerator(test_dict, test_data)
+        result = test_gen.add_missing_data()
+
+        assert_frame_equal(expected, result, check_dtype=False)
+
+    def test_continuous_column_adjusted_to_categorical_missing_data(self):
+        '''
+        Remember that continuous columns depend on values in categorical columns
+        in the same row for their weights, including for Missing data values.
+        Adding Missing data also changes the target_sum of the continuous column
+        so we need to re-scale the whole column after adding missing data either
+        to it or to the categorical columns.
+        
+        We rely on np.random to generate reasonable number of NAs with 0.5 prob,
+        but that can sometimes fail so we ensure that the seed is constant.
+        '''
+
+        Weights = namedtuple("Weights", ["weight", "equal_weight"])
+
+        #demo weights table
+        weights_df = pd.DataFrame(
+            data=[
+                ("C2", "C1", "A", Weights(0.1, 0.5)),
+                ("C2", "C1", "B", Weights(0.9, 0.5)),
+                ("C2", "C1", MISSING_DATA_STR, Weights(0.2, 0.5)),
+            ],
+            columns=["num_col", "cat_col", "cat_value", "weights"])
+
+        #reformat into dictionary
+        weights = (
+            weights_df
+                .set_index(["num_col", "cat_col", "cat_value"])
+                .to_dict(orient="index")
+        )
+
+        test_dict = {
+            "_rng" : np.random.default_rng(seed=0),
+            "metadata": {
+                "categorical_columns": [
+                    "C1"
+                ],
+                "numerical_columns" : [
+                    "C2"
+                ]
+            },
+            "columns": {
+                "C1": {
+                    "type"            : "categorical",
+                    "paired_columns"  : [],
+                    "miss_probability": 0.5,
+                    "original_values" : pd.DataFrame()
+                },
+                "C2": {
+                    "type"            : "continuous",
+                    "precision"       : "integer",
+                    "distribution"    : "weighted_uniform",
+                    "distribution_parameters": {
+                        "uniform_base_value" : 100,
+                        "dispersion": 0,
+                        "target_sum" : 200, # factor of two
+                    },
+                    "miss_probability": 0
+                },
+            },
+            "constraints" : {
+                "custom_constraints" : {}
+            },
+            "linked_columns" : [],
+            "weights_table" : weights,
+            "weights_table_target_cols": ["C1"]
+        }
+
+        test_data = pd.DataFrame(data={
+            "C1" : ["A", "A", "A", "B", "B"] * 20,
+            "C2" : [1] * 100
+        })
+
+
+        test_gen = tm.MissingDataGenerator(test_dict, test_data)
+        result = test_gen.add_missing_data()
+
+        self.assertTrue(result["C1"].isna().any())
+        self.assertEqual(result["C2"].sum(), 200)
+
+    def test_user_linked_columns_having_missing_data(self):
+        '''
+        Because user linked columns can have complex relationships, we 
+        need to make sure missing data is handled correctly.
+        '''
+
+        test_df = pd.DataFrame(data={
+            "A": ["spam", "spam", "eggs", "eggs", "spam"],
+            "B": ["bacon", "spamspam", np.nan, "parrot", "bacon"],
+            "C": range(5)
+        })
+
+        test_dict = {
+            "metadata" : {
+                "number_of_rows" : 1000
+            }
+        }
+
+        fromdata_test = {
+            "linked_columns" : ["A", "B"]
+        }
+
+        _, df = temp_exhibit(
+            filename=test_df, fromdata_namespace=fromdata_test,
+            test_spec_dict=test_dict, return_spec=False)
+
+        self.assertTrue(df.query("A == 'eggs'")["B"].isna().any())    
+
+    def test_categorical_numerical_missing_data_with_make_null_cc(self):
+        '''
+        Typing issues (categorical vs object) can cause bugs when we have categorical columns,
+        a make_null custom constraint, a filter casting categorical column to integers (which
+        assumes object, not categorical - because you can't cast categorical to int if there 
+        is a Missing data categorical value - without removing unused categories first) AND
+        a numerical column. Commenting out the numerical column used to pass the test, and
+        uncommenting it used to fail it - which is wrong.
+
+        Without extra checks, AGE.astype('int') will fail if AGE is dtype="category" because
+        it'll have numbers as strings (which can be cast to int) and "invisible" Missing data
+        which can't.
+        '''
+
+        test_df = pd.DataFrame(data={
+            "AGE": ["1", "2", "3", "4", "4"],
+            "NULLED" : list("ABCAB"),
+            "NUMS": range(5)
+        })
+
+        test_dict = {
+            "metadata" : {
+                "number_of_rows" : 10,
+                "categorical_columns": ["AGE", "NULLED"],
+                "numerical_columns" : ["NUMS"]
+            },
+            "constraints" : {
+                "custom_constraints" : {
+                    "test_nulls" : {
+                        "filter" : "AGE.astype('int') > 1",
+                        "targets" : {"NULLED" : "make_null"}
+                    }
+                }
+            }
+        }
+
+        _, df = temp_exhibit(filename=test_df, test_spec_dict=test_dict, return_spec=False)
+
+        self.assertTrue(df.NULLED.isna().any())    
+        
+if __name__ == "__main__" and __package__ is None:
+    #overwrite __package__ builtin as per PEP 366
+    __package__ = "exhibit"
+    unittest.main(warnings="ignore")
diff --git a/exhibit/core/linkage/tests/test_linkage_hierarchical.py b/exhibit/core/linkage/tests/test_linkage_hierarchical.py
index 0cb3f8e..23a407f 100644
--- a/exhibit/core/linkage/tests/test_linkage_hierarchical.py
+++ b/exhibit/core/linkage/tests/test_linkage_hierarchical.py
@@ -506,7 +506,7 @@ def test_scenario_2_random_4_cols(self):
         #first test that high-level column (A) is correctly split ~20-80
         self.assertAlmostEqual(
             0.2/0.8,
-            result.groupby("A").size().agg(lambda x: x[0]/x[1]),
+            result.groupby("A").size().agg(lambda x: x.iloc[0]/x.iloc[1]),
             delta=0.1
         )
 
@@ -648,7 +648,7 @@ def test_scenario_3_random(self):
         #between A0 and A1 + A2 (derived from children's probabilieis).
         self.assertAlmostEqual(
             0.2/0.8,
-            result.groupby("A").size().agg(lambda x: x[0] / (x[1] + x[2])),
+            result.groupby("A").size().agg(lambda x: x.iloc[0] / (x.iloc[1] + x.iloc[2])),
             delta=0.1
         )
 
@@ -709,7 +709,7 @@ def test_scenario_3_aliased(self):
         #between A0 and A1 + A2 (derived from children's probabilieis).
         self.assertAlmostEqual(
             0.2/0.8,
-            result.groupby("A").size().agg(lambda x: x[0] / (x[1] + x[2])),
+            result.groupby("A").size().agg(lambda x: x.iloc[0] / (x.iloc[1] + x.iloc[2])),
             delta=0.1
         )
 
diff --git a/exhibit/core/tests/test_reference.py b/exhibit/core/tests/test_reference.py
index 8071d0b..46059a9 100644
--- a/exhibit/core/tests/test_reference.py
+++ b/exhibit/core/tests/test_reference.py
@@ -1,796 +1,796 @@
-'''
-Reference tests for the Exhibit package
-'''
-
-# Standard library imports
-import unittest
-from pathlib import Path
-import tempfile
-from os.path import join
-from collections import namedtuple
-
-# External imports
-from pandas.testing import assert_frame_equal
-import pandas as pd
-import numpy as np
-
-# Exhibit imports
-from exhibit.core.utils import package_dir
-from exhibit.db import db_util
-from exhibit.core.constants import MISSING_DATA_STR
-from exhibit.sample.sample import inpatients_anon, uuid_anon
-
-# Module under test
-from exhibit.core import exhibit  as tm
-
-def replace_nested_dict_values(d1, d2):
-    '''
-    Recursive replacement of dictionary values in matching keys
-    or adding new ones.
-    '''
-
-    for key2 in d2:
-        if key2 in d1:
-            if isinstance(d1[key2], dict):
-                replace_nested_dict_values(d1[key2], d2[key2])
-            else:
-                d1[key2] = d2[key2]
-        else:
-            d1[key2] = d2[key2]
-
-def temp_exhibit(
-    filename="inpatients.csv",
-    fromdata_namespace=None,
-    fromspec_namespace=None,
-    test_spec_dict=None,
-    return_spec=True,
-    return_df=True,
-    ):
-    '''
-    A helper method to generate and read custom specifications 
-
-    Parameters
-    ----------
-    filename : str or pd.DataFrame
-        the .csv to use as the base for spec / df generation
-    fromdata_namespace : dict
-        dictionary with testing values for creating a spec
-    fromspec_namespace : dict
-        dictionary with testing values for running generation command
-    test_spec_dict : dict
-        dictionary with testing values for user spec: used to update the spec
-        generated from filename csv or DataFrame
-    return_spec : boolean
-        sometimes you only want to generate the csv and don't need the spec, 
-        like in performance benchmark testing
-    return_df : boolean
-        sometimes you only want to generate a spec; if return_df is False
-        then the second element in the return tuple is None
-
-    Returns
-    -------
-    A named tuples with spec dict and the generated dataframe     
-    '''
-
-    returnTuple = namedtuple("TestRun", ["temp_spec", "temp_df"])
-    temp_spec = None
-    temp_df = None
-
-    if isinstance(filename, dict) or \
-    (isinstance(filename, str) and filename[-3:] == "yml"):
-        source = "yml"
-    else:
-        source = "csv"
-
-    # function has five paths:
-    # 1) given .csv filename (or DataFrame) produce just a spec
-    # 2) given .csv filename (or DataFrame) produce a spec and a demo .csv
-    # 3) given a .yml filename (or dict) produce a demo .csv
-    # 4) given a .yml filename (or dict) produce a spec
-    # 5) given a .yml filename (or dict) produce a spec and a demo .csv
-    
-    # it's important to only generate appropriate parts because they are measured
-    # separetely in performance benchmarking tests.
-
-    # if source is data, we always produce a spec (can't have demo data without it)
-    if source == "csv":
-
-        with tempfile.TemporaryDirectory() as td:
-
-            temp_spec_name = "_.yml"
-            f_name = join(td, temp_spec_name)
-
-            # for internal use when testing with a custom dataframe, not a static file
-            if isinstance(filename, pd.DataFrame):
-                default_data_path = filename
-            else:
-                default_data_path = Path(package_dir("sample", "_data", filename))
-
-            fromdata_defaults = {
-                "command"           : "fromdata",
-                "source"            : default_data_path,
-                "inline_limit"      : 30,
-                "verbose"           : True,
-                "output"            : f_name,
-                "skip_columns"      : [],
-                "equal_weights"     : False,
-                "linked_columns"    : None
-            }
-
-            #Update namespaces
-            if fromdata_namespace:
-                fromdata_defaults.update(fromdata_namespace)
-
-            xA = tm.Exhibit(**fromdata_defaults)
-            xA.read_data()
-            xA.generate_spec()
-
-            if return_df:
-
-                xA.write_spec()
-                fromspec_defaults = {
-                    "command"           : "fromspec",
-                    "source"            : Path(f_name),
-                    "verbose"           : True,
-                }
-
-                if fromspec_namespace:
-                    fromspec_defaults.update(fromspec_namespace)
-
-                xA = tm.Exhibit(**fromspec_defaults)
-                xA.read_spec()
-
-                if test_spec_dict:
-                    replace_nested_dict_values(xA.spec_dict, test_spec_dict)
-
-                if xA.validate_spec():
-                    xA.execute_spec()
-
-                temp_df = xA.anon_df
-            
-            if return_spec:
-                temp_spec=xA.spec_dict
-
-    if source == "yml":
-
-        # for internal use when testing with a custom spec_dict, not a static file
-        if isinstance(filename, dict):
-            default_spec_path = filename
-        else:
-            default_spec_path = Path(package_dir("sample", "_spec", filename))
-
-        fromspec_defaults = {
-            "command"           : "fromspec",
-            "source"            : default_spec_path,
-            "verbose"           : True,
-        }
-
-        if fromspec_namespace:
-            fromspec_defaults.update(fromspec_namespace)
-        
-        xA = tm.Exhibit(**fromspec_defaults)
-        xA.read_spec()
-
-        if test_spec_dict:
-            replace_nested_dict_values(xA.spec_dict, test_spec_dict)
-
-        if return_spec:
-            temp_spec = xA.spec_dict
-        
-        if return_df:
-
-            if xA.validate_spec():
-                xA.execute_spec()
-
-            temp_df = xA.anon_df
-
-    return returnTuple(temp_spec, temp_df)
-
-class referenceTests(unittest.TestCase):
-    '''
-    Main test suite; command line arguments are mocked
-    via patch context manager; internal intermediate functions
-    are mocked inside each test.
-    '''
-
-    @classmethod
-    def setUpClass(cls):
-        '''
-        Create a list of tables to drop after reference tests finish
-        '''
-
-        cls._temp_tables = []
-
-    @classmethod
-    def tearDownClass(cls):
-        '''
-        Clean up local exhibit.db from temp tables
-        '''
-
-        db_util.drop_tables(cls._temp_tables)
-    
-    def test_reference_prescribing_non_linked_anon_data(self):
-        '''
-        What this reference test is covering:
-            - paired 1:1 anonymisation set (birds)
-            - designating paired columns as complete columns
-            - unlinking of columns
-        '''
-
-        expected_df = pd.read_csv(
-            package_dir(
-                "core", "tests", "_reference_data",
-                "prescribing_anon_non_linked.csv"),
-            parse_dates=["PaidDateMonth"]
-            )
-        
-        test_dict = {
-            "metadata":{"number_of_rows":1500},
-            "columns":{
-                "HB2014":{
-                    "cross_join_all_unique_values": True
-                },
-                "HB2014Name":{
-                    "cross_join_all_unique_values": True
-                },
-                "BNFItemCode":{"anonymising_set":"birds"},
-                "BNFItemDescription":{"anonymising_set":"birds"},
-                "GPPracticeName":{"anonymising_set":"random"}
-            },
-            "linked_columns":[]
-        }
-
-        temp_spec, temp_df = temp_exhibit(
-            filename="prescribing.csv",
-            test_spec_dict=test_dict
-        )
-
-        #save ID to tidy up temp columns created as part of testing
-        self._temp_tables.append(temp_spec["metadata"]["id"])
-
-        #sort column names to make sure they are the same
-        temp_df.sort_index(axis=1, inplace=True)
-        expected_df.sort_index(axis=1, inplace=True)
-
-        assert_frame_equal(
-            left=expected_df,
-            right=temp_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-    
-    def test_reference_prescribing_linked_mnt_anon_data(self):
-        '''
-        What this reference test is covering:
-            - one of the linked columns is in the spec, another is in DB
-            - anonymisation is done using "mountains" set
-            - NumberOfPaidItems is generated from a shifted normal distribution
-
-        Note that prescribing dataset has duplicate categorical rows
-        '''
-
-        expected_df = pd.read_csv(
-            package_dir(
-                "core", "tests", "_reference_data",
-                "prescribing_anon_mnt_linked.csv"),
-            parse_dates=["PaidDateMonth"]
-            )
-
-        test_dict = {
-            "columns":{
-                "HB2014":{"anonymising_set":"mountains"},
-                "HB2014Name":{"anonymising_set":"mountains"},
-                "GPPracticeName":{"anonymising_set":"mountains"},
-                "NumberOfPaidItems":{"distribution":"normal"}
-            }
-        }
-
-        temp_spec, temp_df = temp_exhibit(
-            filename="prescribing.csv",
-            test_spec_dict=test_dict
-        )
-
-        #save ID to tidy up temp columns created as part of testing
-        self._temp_tables.append(temp_spec["metadata"]["id"])
-
-        #sort column names to make sure they are the same
-        temp_df.sort_index(axis=1, inplace=True)
-        expected_df.sort_index(axis=1, inplace=True)
-
-        assert_frame_equal(
-            left=expected_df,
-            right=temp_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-    
-    def test_reference_inpatient_anon_data(self):
-        '''
-        What this reference test is covering:
-            - duplicates are removed
-            - manually change labels in Sex column (Female to A, Male to B)
-            - manually added derived column (avlos)
-            - removed linked columns from spec
-            - removed Scotland from HBs and deleted loc columns
-            - changed the totals for stays (100 000) and los (200 000)
-            - changed basic constraint to los >= stays
-            - DB is not used at all so no need for ID
-
-        Note that when basic constraints are added, generated totals can
-        be different from those set in the spec as target sum is enforced
-        BEFORE basic constraints are adjusted.
-        '''
-        
-        args = {
-            "command"      : "fromspec",
-            "source"       : Path(package_dir("sample", "_spec", "inpatients_demo.yml")),
-            "skip_columns" : [],
-            "verbose"      : True,
-        }
-
-        xA = tm.Exhibit(**args)
-        xA.read_spec()
-        if xA.validate_spec():
-            xA.execute_spec()
-
-        table_id = xA.spec_dict["metadata"]["id"]
-        
-        #save ID to tidy up temp columns created as part of testing
-        self._temp_tables.append(table_id)
-
-        #sort column names to make sure they are the same
-        inpatients_anon.sort_index(axis=1, inplace=True)
-        xA.anon_df.sort_index(axis=1, inplace=True)
-
-        # there is a quirk of how int is cast on Windows and Unix: int32 vs int64
-        # see SO answer:
-        # Why do Pandas integer `dtypes` not behave the same on Unix and Windows?
-        assert_frame_equal(
-            left=inpatients_anon,
-            right=xA.anon_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-
-    def test_reference_inpatient_il10_random_data(self):
-        '''
-        What this reference test is covering:
-            - number of unique values exceeds inline limit in all linked columns
-            - anonymisation method is "random"
-            - non-linked categorical column (Sex) has missing data
-            - linked columns share missing categorical data
-
-        Because by default the spec includes the basic constraints of los >= avlos,
-        if avlos is null (0.065 probability in source data) then los will also be null
-        in ~130 records.
-        '''
-
-        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
-        
-        # dayfirst=True would trigger warnings when encountering dates in Y-m-d format
-        test_dataframe = pd.read_csv(
-            source_data_path,
-            parse_dates=["quarter_date"],
-        )
-
-        # Modify test_dataframe to suit test conditions
-        # Gives us 500/10225 ~ 5% chance of missing data
-        rng = np.random.default_rng(seed=0)
-        rand_idx = rng.choice(
-            range(test_dataframe.shape[0]),
-            size=500,
-            replace=False)
-
-        linked_cols = ["hb_code", "hb_name", "loc_code", "loc_name"]
-        test_dataframe.loc[rand_idx, linked_cols] = (np.NaN, np.NaN, np.NaN, np.NaN)
-
-        # Gives us ~10% chance of missing data
-        rand_idx2 = rng.choice(
-            range(test_dataframe.shape[0]),
-            size=1000,
-            replace=False)
-
-        na_cols = ["sex"]
-        test_dataframe.loc[rand_idx2, na_cols] = np.NaN
-
-        # modify CLI namespace
-        fromdata_namespace = {
-            "source"            : test_dataframe,
-            "inline_limit": 10,
-        }
-        
-        # modify spec
-        test_spec_dict = {
-            "metadata": {"number_of_rows": 2000, "random_seed": 2},
-            "columns" : {"sex": {"cross_join_all_unique_values" : True}}
-        }
-
-        temp_spec, temp_df = temp_exhibit(
-            fromdata_namespace=fromdata_namespace,
-            test_spec_dict=test_spec_dict
-            )
-
-        inpatients_anon_il10 = pd.read_csv(
-            package_dir(
-                "core", "tests", "_reference_data",
-                "inpatients_anon_rnd_il10.csv"),
-                parse_dates=["quarter_date"]
-            )
-
-        #save ID to tidy up temp columns created as part of testing
-        table_id = temp_spec["metadata"]["id"]
-        self._temp_tables.append(table_id)
-            
-        assert_frame_equal(
-            left=inpatients_anon_il10,
-            right=temp_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-
-    def test_reference_inpatient_il50_random_data(self):
-        '''
-        What this reference test is covering:
-            - number of unique values is within inline limit in all columns
-            - anonymisation method is "random"
-            - linked columns share missing categorical data
-            - manually change date frequency from QS to M
-        '''
-
-        rng = np.random.default_rng(seed=0)
-
-        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
-
-        test_dataframe = pd.read_csv(
-            source_data_path,
-            parse_dates=["quarter_date"],
-        )
-
-        # Modify test_dataframe to suit test conditions
-        rand_idx = rng.choice(
-            range(test_dataframe.shape[0]),
-            size=500,
-            replace=False)
-
-        linked_cols = ["hb_code", "hb_name", "loc_code", "loc_name"]
-        test_dataframe.loc[rand_idx, linked_cols] = (np.NaN, np.NaN, np.NaN, np.NaN)
-
-        # modify CLI namespace
-        fromdata_namespace = {
-            "source"            : test_dataframe,
-            "inline_limit": 50,
-        }
-
-        # modify spec
-        test_spec_dict = {
-            "metadata": {"number_of_rows": 2000},
-            "columns" : {"quarter_date": 
-                    {"from" : "2018-01-01", "frequency": "M"}
-                }
-            }
-
-        temp_spec, temp_df = temp_exhibit(
-            fromdata_namespace=fromdata_namespace,
-            test_spec_dict=test_spec_dict
-            )
-
-        inpatients_anon_il50 = pd.read_csv(
-            package_dir(
-                "core", "tests", "_reference_data",
-                "inpatients_anon_rnd_il50.csv"),
-            parse_dates=["quarter_date"]
-            )
-
-        #save ID to tidy up temp columns created as part of testing
-        table_id = temp_spec["metadata"]["id"]
-        self._temp_tables.append(table_id)
-            
-        assert_frame_equal(
-            left=inpatients_anon_il50,
-            right=temp_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-
-    def test_reference_inpatient_il10_mountains_data(self):
-        '''
-        What this reference test is covering:
-            - number of unique values exceeds inline limit in all columns
-            - anonymisation method is hierarchical "mountains"
-            - anon columns are specified using dot notation
-            - sex is a "complete" categorical column, but there will be gaps
-            where missind data is generated in other columns - categorical
-            values are generated first, and then "blanked" based on miss_pct            
-            - only the most granular linked column has missing values
-            - avlos is not derived and is calculated "blindly"
-        '''
-
-        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
-
-        test_dataframe = pd.read_csv(
-            source_data_path,
-            parse_dates=["quarter_date"],
-        )
-
-        # Modify test_dataframe to suit test conditions
-        rng = np.random.default_rng(seed=0)
-        rand_idx = rng.choice(
-            range(test_dataframe.shape[0]),
-            size=500,
-            replace=False)
-
-        linked_cols = ["loc_code", "loc_name"]
-        test_dataframe.loc[rand_idx, linked_cols] = (np.NaN, np.NaN)
-
-        # modify CLI namespace
-        fromdata_namespace = {
-            "source"            : test_dataframe,
-            "inline_limit": 10,
-        }
-
-        # Modify test_dataframe to suit test conditions
-        test_spec_dict = {
-            "metadata": 
-                {"number_of_rows": 2000},
-            "columns": {
-                "sex" : 
-                    {"cross_join_all_unique_values": True}
-                ,
-                "hb_code": 
-                    {"anonymising_set":"mountains.range"}
-                ,
-                "hb_name": 
-                    {"anonymising_set":"mountains.range"}
-                ,
-                "loc_code": 
-                    {"anonymising_set":"mountains.peak"}
-                ,
-                "loc_name": 
-                    {"anonymising_set":"mountains.peak"}
-                },
-            "constraints": {
-                "basic_constraints" : {}
-            }
-        }
-                 
-        temp_spec, temp_df = temp_exhibit(
-            fromdata_namespace=fromdata_namespace,
-            test_spec_dict=test_spec_dict
-            )
-
-        inpatients_anon_mnt_il10 = pd.read_csv(
-            package_dir(
-                "core", "tests", "_reference_data",
-                "inpatients_anon_mnt_il10.csv"),
-            parse_dates=["quarter_date"]
-            )
-
-        #save ID to tidy up temp columns created as part of testing
-        table_id = temp_spec["metadata"]["id"]
-        self._temp_tables.append(table_id)
-            
-        assert_frame_equal(
-            left=inpatients_anon_mnt_il10,
-            right=temp_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-
-    def test_reference_inpatient_il50_mountains_data(self):
-        '''
-        What this reference test is covering:
-            - number of unique values is within inline limit in all columns
-            - anonymisation method is hierarchical "mountains"
-            - linked columns share missing categorical data
-        '''
-
-        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
-    
-        test_dataframe = pd.read_csv(
-            source_data_path,
-            parse_dates=["quarter_date"],
-        )
-
-        # Modify test_dataframe to suit test conditions
-        rng = np.random.default_rng(seed=0)
-        rand_idx = rng.choice(
-            range(test_dataframe.shape[0]),
-            size=500,
-            replace=False)
-
-        linked_cols = ["hb_code", "hb_name", "loc_code", "loc_name"]
-        test_dataframe.loc[rand_idx, linked_cols] = (np.NaN, np.NaN, np.NaN, np.NaN)
-
-        # modify CLI namespace
-        fromdata_namespace = {
-            "source"            : test_dataframe,
-            "inline_limit": 50,
-        }
-
-        # modify spec
-        test_spec_dict = {
-            "metadata": 
-                {"number_of_rows": 2000},
-            "columns": {
-                "hb_code": 
-                    {"anonymising_set":"mountains"}
-                ,
-                "hb_name": 
-                    {"anonymising_set":"mountains"}
-                ,
-                "loc_code": 
-                    {"anonymising_set":"mountains"}
-                ,
-                "loc_name": 
-                    {"anonymising_set":"mountains"}
-                },
-        }
-                 
-        temp_spec, temp_df = temp_exhibit(
-            fromdata_namespace=fromdata_namespace,
-            test_spec_dict=test_spec_dict
-            )
-
-        inpatients_anon_mnt_il50 = pd.read_csv(
-            package_dir(
-                "core", "tests", "_reference_data",
-                "inpatients_anon_mnt_il50.csv"),
-            parse_dates=["quarter_date"]
-            )
-
-        #save ID to tidy up temp columns created as part of testing
-        table_id = temp_spec["metadata"]["id"]
-        self._temp_tables.append(table_id)
-
-        assert_frame_equal(
-            left=inpatients_anon_mnt_il50,
-            right=temp_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-
-    def test_reference_inpatient_modified_linked_columns_scenario_2(self):
-        '''
-        What this reference test is covering:
-         - scenario 2
-         - custom value in one of the linked columns
-         - number of linked columns in spec is less than in original SQL
-        '''
-
-        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
-
-        test_dataframe = pd.read_csv(
-            source_data_path,
-            parse_dates=["quarter_date"],
-        )
-
-        # modify CLI namespace
-        fromdata_namespace = {
-            "source"            : test_dataframe,
-        }
-        
-        # modify spec
-        test_spec_dict = {
-            "metadata": {"number_of_rows": 2000, "random_seed": 0},
-            "columns": {
-                "hb_name" : {
-                    "uniques" : 2,
-                    "original_values" : pd.DataFrame(data={
-                        "hb_name": ["PHS A&A", "NHS Borders", MISSING_DATA_STR],
-                        "paired_hb_code": ["S08000015", "S08000016", MISSING_DATA_STR],
-                        "probability_vector" : [0.5, 0.5, 0],
-                        "avlos": [0.5, 0.5, 0],
-                        "los": [0.5, 0.5, 0],
-                        "stays": [0.5, 0.5, 0]})
-                }
-            }
-        }
-
-        temp_spec, temp_df = temp_exhibit(
-            fromdata_namespace=fromdata_namespace,
-            test_spec_dict=test_spec_dict,
-            )
-       
-        #save ID to tidy up temp columns created as part of testing
-        table_id = temp_spec["metadata"]["id"]
-        self._temp_tables.append(table_id)
-
-        self.assertCountEqual(
-            temp_df["hb_name"].unique(),
-            ["PHS A&A", "NHS Borders"])
-
-    def test_reference_inpatient_modified_linked_columns_scenario_3(self):
-        '''
-        What this reference test is covering:
-         - scenario 3
-         - custom value in one of the linked columns
-         - number of linked columns in spec is less than in original SQL
-        '''
-
-        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
-
-        test_dataframe = pd.read_csv(
-            source_data_path,
-            parse_dates=["quarter_date"],
-        )
-
-        # modify CLI namespace
-        fromdata_namespace = {
-            "source"            : test_dataframe,
-            "inline_limit": 50
-        }
-        
-        # modify spec
-        test_spec_dict = {
-            "metadata": {"number_of_rows": 2000, "random_seed": 0},
-            "columns": {
-                "loc_name" : {
-                    "uniques" : 5,
-                    "original_values" : pd.DataFrame(data={
-                        "loc_name": list("ABCDE") + [MISSING_DATA_STR],
-                        "paired_loc_code": list("ABCDE") + [MISSING_DATA_STR],
-                        "probability_vector" : [0.2] * 5 + [0],
-                        "avlos": [0.2] * 5 + [0],
-                        "los": [0.2] * 5 + [0],
-                        "stays": [0.2] * 5 + [0]})
-                }
-            }
-        }
-
-        temp_spec, temp_df = temp_exhibit(
-            fromdata_namespace=fromdata_namespace,
-            test_spec_dict=test_spec_dict,
-            )
-       
-        #save ID to tidy up temp columns created as part of testing
-        table_id = temp_spec["metadata"]["id"]
-        self._temp_tables.append(table_id)
-
-        self.assertCountEqual(temp_df["loc_name"].unique(), list("ABCDE"))
-
-    def test_reference_uuid_data(self):
-        '''
-        What this reference test is covering:
-            - uuid column type
-            - generate_as_sequence, make_same and sorting custom actions
-            - no db
-        '''
-        
-        args = {
-            "command"      : "fromspec",
-            "source"       : Path(package_dir("sample", "_spec", "uuid_demo.yml")),
-            "skip_columns" : [],
-            "verbose"      : True,
-        }
-
-        xA = tm.Exhibit(**args)
-        xA.read_spec()
-        if xA.validate_spec():
-            xA.execute_spec()
-
-        #sort column names to make sure they are the same
-        uuid_anon.sort_index(axis=1, inplace=True)
-        xA.anon_df.sort_index(axis=1, inplace=True)
-
-        # there is a quirk of how int is cast on Windows and Unix: int32 vs int64
-        # see SO answer:
-        # Why do Pandas integer `dtypes` not behave the same on Unix and Windows?
-        assert_frame_equal(
-            left=uuid_anon,
-            right=xA.anon_df,
-            check_exact=False,
-            check_dtype=False,
-            check_categorical=False
-        )
-
-if __name__ == "__main__" and __package__ is None:
-    #overwrite __package__ builtin as per PEP 366
-    __package__ = "exhibit"
-    unittest.main(warnings="ignore")
+'''
+Reference tests for the Exhibit package
+'''
+
+# Standard library imports
+import unittest
+from pathlib import Path
+import tempfile
+from os.path import join
+from collections import namedtuple
+
+# External imports
+from pandas.testing import assert_frame_equal
+import pandas as pd
+import numpy as np
+
+# Exhibit imports
+from exhibit.core.utils import package_dir
+from exhibit.db import db_util
+from exhibit.core.constants import MISSING_DATA_STR
+from exhibit.sample.sample import inpatients_anon, uuid_anon
+
+# Module under test
+from exhibit.core import exhibit  as tm
+
+def replace_nested_dict_values(d1, d2):
+    '''
+    Recursive replacement of dictionary values in matching keys
+    or adding new ones.
+    '''
+
+    for key2 in d2:
+        if key2 in d1:
+            if isinstance(d1[key2], dict):
+                replace_nested_dict_values(d1[key2], d2[key2])
+            else:
+                d1[key2] = d2[key2]
+        else:
+            d1[key2] = d2[key2]
+
+def temp_exhibit(
+    filename="inpatients.csv",
+    fromdata_namespace=None,
+    fromspec_namespace=None,
+    test_spec_dict=None,
+    return_spec=True,
+    return_df=True,
+    ):
+    '''
+    A helper method to generate and read custom specifications 
+
+    Parameters
+    ----------
+    filename : str or pd.DataFrame
+        the .csv to use as the base for spec / df generation
+    fromdata_namespace : dict
+        dictionary with testing values for creating a spec
+    fromspec_namespace : dict
+        dictionary with testing values for running generation command
+    test_spec_dict : dict
+        dictionary with testing values for user spec: used to update the spec
+        generated from filename csv or DataFrame
+    return_spec : boolean
+        sometimes you only want to generate the csv and don't need the spec, 
+        like in performance benchmark testing
+    return_df : boolean
+        sometimes you only want to generate a spec; if return_df is False
+        then the second element in the return tuple is None
+
+    Returns
+    -------
+    A named tuples with spec dict and the generated dataframe     
+    '''
+
+    returnTuple = namedtuple("TestRun", ["temp_spec", "temp_df"])
+    temp_spec = None
+    temp_df = None
+
+    if isinstance(filename, dict) or \
+    (isinstance(filename, str) and filename[-3:] == "yml"):
+        source = "yml"
+    else:
+        source = "csv"
+
+    # function has five paths:
+    # 1) given .csv filename (or DataFrame) produce just a spec
+    # 2) given .csv filename (or DataFrame) produce a spec and a demo .csv
+    # 3) given a .yml filename (or dict) produce a demo .csv
+    # 4) given a .yml filename (or dict) produce a spec
+    # 5) given a .yml filename (or dict) produce a spec and a demo .csv
+    
+    # it's important to only generate appropriate parts because they are measured
+    # separetely in performance benchmarking tests.
+
+    # if source is data, we always produce a spec (can't have demo data without it)
+    if source == "csv":
+
+        with tempfile.TemporaryDirectory() as td:
+
+            temp_spec_name = "_.yml"
+            f_name = join(td, temp_spec_name)
+
+            # for internal use when testing with a custom dataframe, not a static file
+            if isinstance(filename, pd.DataFrame):
+                default_data_path = filename
+            else:
+                default_data_path = Path(package_dir("sample", "_data", filename))
+
+            fromdata_defaults = {
+                "command"           : "fromdata",
+                "source"            : default_data_path,
+                "inline_limit"      : 30,
+                "verbose"           : True,
+                "output"            : f_name,
+                "skip_columns"      : [],
+                "equal_weights"     : False,
+                "linked_columns"    : None
+            }
+
+            #Update namespaces
+            if fromdata_namespace:
+                fromdata_defaults.update(fromdata_namespace)
+
+            xA = tm.Exhibit(**fromdata_defaults)
+            xA.read_data()
+            xA.generate_spec()
+
+            if return_df:
+
+                xA.write_spec()
+                fromspec_defaults = {
+                    "command"           : "fromspec",
+                    "source"            : Path(f_name),
+                    "verbose"           : True,
+                }
+
+                if fromspec_namespace:
+                    fromspec_defaults.update(fromspec_namespace)
+
+                xA = tm.Exhibit(**fromspec_defaults)
+                xA.read_spec()
+
+                if test_spec_dict:
+                    replace_nested_dict_values(xA.spec_dict, test_spec_dict)
+
+                if xA.validate_spec():
+                    xA.execute_spec()
+
+                temp_df = xA.anon_df
+            
+            if return_spec:
+                temp_spec=xA.spec_dict
+
+    if source == "yml":
+
+        # for internal use when testing with a custom spec_dict, not a static file
+        if isinstance(filename, dict):
+            default_spec_path = filename
+        else:
+            default_spec_path = Path(package_dir("sample", "_spec", filename))
+
+        fromspec_defaults = {
+            "command"           : "fromspec",
+            "source"            : default_spec_path,
+            "verbose"           : True,
+        }
+
+        if fromspec_namespace:
+            fromspec_defaults.update(fromspec_namespace)
+        
+        xA = tm.Exhibit(**fromspec_defaults)
+        xA.read_spec()
+
+        if test_spec_dict:
+            replace_nested_dict_values(xA.spec_dict, test_spec_dict)
+
+        if return_spec:
+            temp_spec = xA.spec_dict
+        
+        if return_df:
+
+            if xA.validate_spec():
+                xA.execute_spec()
+
+            temp_df = xA.anon_df
+
+    return returnTuple(temp_spec, temp_df)
+
+class referenceTests(unittest.TestCase):
+    '''
+    Main test suite; command line arguments are mocked
+    via patch context manager; internal intermediate functions
+    are mocked inside each test.
+    '''
+
+    @classmethod
+    def setUpClass(cls):
+        '''
+        Create a list of tables to drop after reference tests finish
+        '''
+
+        cls._temp_tables = []
+
+    @classmethod
+    def tearDownClass(cls):
+        '''
+        Clean up local exhibit.db from temp tables
+        '''
+
+        db_util.drop_tables(cls._temp_tables)
+    
+    def test_reference_prescribing_non_linked_anon_data(self):
+        '''
+        What this reference test is covering:
+            - paired 1:1 anonymisation set (birds)
+            - designating paired columns as complete columns
+            - unlinking of columns
+        '''
+
+        expected_df = pd.read_csv(
+            package_dir(
+                "core", "tests", "_reference_data",
+                "prescribing_anon_non_linked.csv"),
+            parse_dates=["PaidDateMonth"]
+            )
+        
+        test_dict = {
+            "metadata":{"number_of_rows":1500},
+            "columns":{
+                "HB2014":{
+                    "cross_join_all_unique_values": True
+                },
+                "HB2014Name":{
+                    "cross_join_all_unique_values": True
+                },
+                "BNFItemCode":{"anonymising_set":"birds"},
+                "BNFItemDescription":{"anonymising_set":"birds"},
+                "GPPracticeName":{"anonymising_set":"random"}
+            },
+            "linked_columns":[]
+        }
+
+        temp_spec, temp_df = temp_exhibit(
+            filename="prescribing.csv",
+            test_spec_dict=test_dict
+        )
+
+        #save ID to tidy up temp columns created as part of testing
+        self._temp_tables.append(temp_spec["metadata"]["id"])
+
+        #sort column names to make sure they are the same
+        temp_df.sort_index(axis=1, inplace=True)
+        expected_df.sort_index(axis=1, inplace=True)
+
+        assert_frame_equal(
+            left=expected_df,
+            right=temp_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+    
+    def test_reference_prescribing_linked_mnt_anon_data(self):
+        '''
+        What this reference test is covering:
+            - one of the linked columns is in the spec, another is in DB
+            - anonymisation is done using "mountains" set
+            - NumberOfPaidItems is generated from a shifted normal distribution
+
+        Note that prescribing dataset has duplicate categorical rows
+        '''
+
+        expected_df = pd.read_csv(
+            package_dir(
+                "core", "tests", "_reference_data",
+                "prescribing_anon_mnt_linked.csv"),
+            parse_dates=["PaidDateMonth"]
+            )
+
+        test_dict = {
+            "columns":{
+                "HB2014":{"anonymising_set":"mountains"},
+                "HB2014Name":{"anonymising_set":"mountains"},
+                "GPPracticeName":{"anonymising_set":"mountains"},
+                "NumberOfPaidItems":{"distribution":"normal"}
+            }
+        }
+
+        temp_spec, temp_df = temp_exhibit(
+            filename="prescribing.csv",
+            test_spec_dict=test_dict
+        )
+
+        #save ID to tidy up temp columns created as part of testing
+        self._temp_tables.append(temp_spec["metadata"]["id"])
+
+        #sort column names to make sure they are the same
+        temp_df.sort_index(axis=1, inplace=True)
+        expected_df.sort_index(axis=1, inplace=True)
+
+        assert_frame_equal(
+            left=expected_df,
+            right=temp_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+    
+    def test_reference_inpatient_anon_data(self):
+        '''
+        What this reference test is covering:
+            - duplicates are removed
+            - manually change labels in Sex column (Female to A, Male to B)
+            - manually added derived column (avlos)
+            - removed linked columns from spec
+            - removed Scotland from HBs and deleted loc columns
+            - changed the totals for stays (100 000) and los (200 000)
+            - changed basic constraint to los >= stays
+            - DB is not used at all so no need for ID
+
+        Note that when basic constraints are added, generated totals can
+        be different from those set in the spec as target sum is enforced
+        BEFORE basic constraints are adjusted.
+        '''
+        
+        args = {
+            "command"      : "fromspec",
+            "source"       : Path(package_dir("sample", "_spec", "inpatients_demo.yml")),
+            "skip_columns" : [],
+            "verbose"      : True,
+        }
+
+        xA = tm.Exhibit(**args)
+        xA.read_spec()
+        if xA.validate_spec():
+            xA.execute_spec()
+
+        table_id = xA.spec_dict["metadata"]["id"]
+        
+        #save ID to tidy up temp columns created as part of testing
+        self._temp_tables.append(table_id)
+
+        #sort column names to make sure they are the same
+        inpatients_anon.sort_index(axis=1, inplace=True)
+        xA.anon_df.sort_index(axis=1, inplace=True)
+
+        # there is a quirk of how int is cast on Windows and Unix: int32 vs int64
+        # see SO answer:
+        # Why do Pandas integer `dtypes` not behave the same on Unix and Windows?
+        assert_frame_equal(
+            left=inpatients_anon,
+            right=xA.anon_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+
+    def test_reference_inpatient_il10_random_data(self):
+        '''
+        What this reference test is covering:
+            - number of unique values exceeds inline limit in all linked columns
+            - anonymisation method is "random"
+            - non-linked categorical column (Sex) has missing data
+            - linked columns share missing categorical data
+
+        Because by default the spec includes the basic constraints of los >= avlos,
+        if avlos is null (0.065 probability in source data) then los will also be null
+        in ~130 records.
+        '''
+
+        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
+        
+        # dayfirst=True would trigger warnings when encountering dates in Y-m-d format
+        test_dataframe = pd.read_csv(
+            source_data_path,
+            parse_dates=["quarter_date"],
+        )
+
+        # Modify test_dataframe to suit test conditions
+        # Gives us 500/10225 ~ 5% chance of missing data
+        rng = np.random.default_rng(seed=0)
+        rand_idx = rng.choice(
+            range(test_dataframe.shape[0]),
+            size=500,
+            replace=False)
+
+        linked_cols = ["hb_code", "hb_name", "loc_code", "loc_name"]
+        test_dataframe.loc[rand_idx, linked_cols] = (np.nan, np.nan, np.nan, np.nan)
+
+        # Gives us ~10% chance of missing data
+        rand_idx2 = rng.choice(
+            range(test_dataframe.shape[0]),
+            size=1000,
+            replace=False)
+
+        na_cols = ["sex"]
+        test_dataframe.loc[rand_idx2, na_cols] = np.nan
+
+        # modify CLI namespace
+        fromdata_namespace = {
+            "source"            : test_dataframe,
+            "inline_limit": 10,
+        }
+        
+        # modify spec
+        test_spec_dict = {
+            "metadata": {"number_of_rows": 2000, "random_seed": 2},
+            "columns" : {"sex": {"cross_join_all_unique_values" : True}}
+        }
+
+        temp_spec, temp_df = temp_exhibit(
+            fromdata_namespace=fromdata_namespace,
+            test_spec_dict=test_spec_dict
+            )
+
+        inpatients_anon_il10 = pd.read_csv(
+            package_dir(
+                "core", "tests", "_reference_data",
+                "inpatients_anon_rnd_il10.csv"),
+                parse_dates=["quarter_date"]
+            )
+
+        #save ID to tidy up temp columns created as part of testing
+        table_id = temp_spec["metadata"]["id"]
+        self._temp_tables.append(table_id)
+            
+        assert_frame_equal(
+            left=inpatients_anon_il10,
+            right=temp_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+
+    def test_reference_inpatient_il50_random_data(self):
+        '''
+        What this reference test is covering:
+            - number of unique values is within inline limit in all columns
+            - anonymisation method is "random"
+            - linked columns share missing categorical data
+            - manually change date frequency from QS to M
+        '''
+
+        rng = np.random.default_rng(seed=0)
+
+        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
+
+        test_dataframe = pd.read_csv(
+            source_data_path,
+            parse_dates=["quarter_date"],
+        )
+
+        # Modify test_dataframe to suit test conditions
+        rand_idx = rng.choice(
+            range(test_dataframe.shape[0]),
+            size=500,
+            replace=False)
+
+        linked_cols = ["hb_code", "hb_name", "loc_code", "loc_name"]
+        test_dataframe.loc[rand_idx, linked_cols] = (np.nan, np.nan, np.nan, np.nan)
+
+        # modify CLI namespace
+        fromdata_namespace = {
+            "source"            : test_dataframe,
+            "inline_limit": 50,
+        }
+
+        # modify spec
+        test_spec_dict = {
+            "metadata": {"number_of_rows": 2000},
+            "columns" : {"quarter_date": 
+                    {"from" : "2018-01-01", "frequency": "ME"}
+                }
+            }
+
+        temp_spec, temp_df = temp_exhibit(
+            fromdata_namespace=fromdata_namespace,
+            test_spec_dict=test_spec_dict
+            )
+
+        inpatients_anon_il50 = pd.read_csv(
+            package_dir(
+                "core", "tests", "_reference_data",
+                "inpatients_anon_rnd_il50.csv"),
+            parse_dates=["quarter_date"]
+            )
+
+        #save ID to tidy up temp columns created as part of testing
+        table_id = temp_spec["metadata"]["id"]
+        self._temp_tables.append(table_id)
+            
+        assert_frame_equal(
+            left=inpatients_anon_il50,
+            right=temp_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+
+    def test_reference_inpatient_il10_mountains_data(self):
+        '''
+        What this reference test is covering:
+            - number of unique values exceeds inline limit in all columns
+            - anonymisation method is hierarchical "mountains"
+            - anon columns are specified using dot notation
+            - sex is a "complete" categorical column, but there will be gaps
+            where missind data is generated in other columns - categorical
+            values are generated first, and then "blanked" based on miss_pct            
+            - only the most granular linked column has missing values
+            - avlos is not derived and is calculated "blindly"
+        '''
+
+        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
+
+        test_dataframe = pd.read_csv(
+            source_data_path,
+            parse_dates=["quarter_date"],
+        )
+
+        # Modify test_dataframe to suit test conditions
+        rng = np.random.default_rng(seed=0)
+        rand_idx = rng.choice(
+            range(test_dataframe.shape[0]),
+            size=500,
+            replace=False)
+
+        linked_cols = ["loc_code", "loc_name"]
+        test_dataframe.loc[rand_idx, linked_cols] = (np.nan, np.nan)
+
+        # modify CLI namespace
+        fromdata_namespace = {
+            "source"            : test_dataframe,
+            "inline_limit": 10,
+        }
+
+        # Modify test_dataframe to suit test conditions
+        test_spec_dict = {
+            "metadata": 
+                {"number_of_rows": 2000},
+            "columns": {
+                "sex" : 
+                    {"cross_join_all_unique_values": True}
+                ,
+                "hb_code": 
+                    {"anonymising_set":"mountains.range"}
+                ,
+                "hb_name": 
+                    {"anonymising_set":"mountains.range"}
+                ,
+                "loc_code": 
+                    {"anonymising_set":"mountains.peak"}
+                ,
+                "loc_name": 
+                    {"anonymising_set":"mountains.peak"}
+                },
+            "constraints": {
+                "basic_constraints" : {}
+            }
+        }
+                 
+        temp_spec, temp_df = temp_exhibit(
+            fromdata_namespace=fromdata_namespace,
+            test_spec_dict=test_spec_dict
+            )
+
+        inpatients_anon_mnt_il10 = pd.read_csv(
+            package_dir(
+                "core", "tests", "_reference_data",
+                "inpatients_anon_mnt_il10.csv"),
+            parse_dates=["quarter_date"]
+            )
+
+        #save ID to tidy up temp columns created as part of testing
+        table_id = temp_spec["metadata"]["id"]
+        self._temp_tables.append(table_id)
+            
+        assert_frame_equal(
+            left=inpatients_anon_mnt_il10,
+            right=temp_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+
+    def test_reference_inpatient_il50_mountains_data(self):
+        '''
+        What this reference test is covering:
+            - number of unique values is within inline limit in all columns
+            - anonymisation method is hierarchical "mountains"
+            - linked columns share missing categorical data
+        '''
+
+        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
+    
+        test_dataframe = pd.read_csv(
+            source_data_path,
+            parse_dates=["quarter_date"],
+        )
+
+        # Modify test_dataframe to suit test conditions
+        rng = np.random.default_rng(seed=0)
+        rand_idx = rng.choice(
+            range(test_dataframe.shape[0]),
+            size=500,
+            replace=False)
+
+        linked_cols = ["hb_code", "hb_name", "loc_code", "loc_name"]
+        test_dataframe.loc[rand_idx, linked_cols] = (np.nan, np.nan, np.nan, np.nan)
+
+        # modify CLI namespace
+        fromdata_namespace = {
+            "source"            : test_dataframe,
+            "inline_limit": 50,
+        }
+
+        # modify spec
+        test_spec_dict = {
+            "metadata": 
+                {"number_of_rows": 2000},
+            "columns": {
+                "hb_code": 
+                    {"anonymising_set":"mountains"}
+                ,
+                "hb_name": 
+                    {"anonymising_set":"mountains"}
+                ,
+                "loc_code": 
+                    {"anonymising_set":"mountains"}
+                ,
+                "loc_name": 
+                    {"anonymising_set":"mountains"}
+                },
+        }
+                 
+        temp_spec, temp_df = temp_exhibit(
+            fromdata_namespace=fromdata_namespace,
+            test_spec_dict=test_spec_dict
+            )
+
+        inpatients_anon_mnt_il50 = pd.read_csv(
+            package_dir(
+                "core", "tests", "_reference_data",
+                "inpatients_anon_mnt_il50.csv"),
+            parse_dates=["quarter_date"]
+            )
+
+        #save ID to tidy up temp columns created as part of testing
+        table_id = temp_spec["metadata"]["id"]
+        self._temp_tables.append(table_id)
+
+        assert_frame_equal(
+            left=inpatients_anon_mnt_il50,
+            right=temp_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+
+    def test_reference_inpatient_modified_linked_columns_scenario_2(self):
+        '''
+        What this reference test is covering:
+         - scenario 2
+         - custom value in one of the linked columns
+         - number of linked columns in spec is less than in original SQL
+        '''
+
+        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
+
+        test_dataframe = pd.read_csv(
+            source_data_path,
+            parse_dates=["quarter_date"],
+        )
+
+        # modify CLI namespace
+        fromdata_namespace = {
+            "source"            : test_dataframe,
+        }
+        
+        # modify spec
+        test_spec_dict = {
+            "metadata": {"number_of_rows": 2000, "random_seed": 0},
+            "columns": {
+                "hb_name" : {
+                    "uniques" : 2,
+                    "original_values" : pd.DataFrame(data={
+                        "hb_name": ["PHS A&A", "NHS Borders", MISSING_DATA_STR],
+                        "paired_hb_code": ["S08000015", "S08000016", MISSING_DATA_STR],
+                        "probability_vector" : [0.5, 0.5, 0],
+                        "avlos": [0.5, 0.5, 0],
+                        "los": [0.5, 0.5, 0],
+                        "stays": [0.5, 0.5, 0]})
+                }
+            }
+        }
+
+        temp_spec, temp_df = temp_exhibit(
+            fromdata_namespace=fromdata_namespace,
+            test_spec_dict=test_spec_dict,
+            )
+       
+        #save ID to tidy up temp columns created as part of testing
+        table_id = temp_spec["metadata"]["id"]
+        self._temp_tables.append(table_id)
+
+        self.assertCountEqual(
+            temp_df["hb_name"].unique(),
+            ["PHS A&A", "NHS Borders"])
+
+    def test_reference_inpatient_modified_linked_columns_scenario_3(self):
+        '''
+        What this reference test is covering:
+         - scenario 3
+         - custom value in one of the linked columns
+         - number of linked columns in spec is less than in original SQL
+        '''
+
+        source_data_path = Path(package_dir("sample", "_data", "inpatients.csv"))
+
+        test_dataframe = pd.read_csv(
+            source_data_path,
+            parse_dates=["quarter_date"],
+        )
+
+        # modify CLI namespace
+        fromdata_namespace = {
+            "source"            : test_dataframe,
+            "inline_limit": 50
+        }
+        
+        # modify spec
+        test_spec_dict = {
+            "metadata": {"number_of_rows": 2000, "random_seed": 0},
+            "columns": {
+                "loc_name" : {
+                    "uniques" : 5,
+                    "original_values" : pd.DataFrame(data={
+                        "loc_name": list("ABCDE") + [MISSING_DATA_STR],
+                        "paired_loc_code": list("ABCDE") + [MISSING_DATA_STR],
+                        "probability_vector" : [0.2] * 5 + [0],
+                        "avlos": [0.2] * 5 + [0],
+                        "los": [0.2] * 5 + [0],
+                        "stays": [0.2] * 5 + [0]})
+                }
+            }
+        }
+
+        temp_spec, temp_df = temp_exhibit(
+            fromdata_namespace=fromdata_namespace,
+            test_spec_dict=test_spec_dict,
+            )
+       
+        #save ID to tidy up temp columns created as part of testing
+        table_id = temp_spec["metadata"]["id"]
+        self._temp_tables.append(table_id)
+
+        self.assertCountEqual(temp_df["loc_name"].unique(), list("ABCDE"))
+
+    def test_reference_uuid_data(self):
+        '''
+        What this reference test is covering:
+            - uuid column type
+            - generate_as_sequence, make_same and sorting custom actions
+            - no db
+        '''
+        
+        args = {
+            "command"      : "fromspec",
+            "source"       : Path(package_dir("sample", "_spec", "uuid_demo.yml")),
+            "skip_columns" : [],
+            "verbose"      : True,
+        }
+
+        xA = tm.Exhibit(**args)
+        xA.read_spec()
+        if xA.validate_spec():
+            xA.execute_spec()
+
+        #sort column names to make sure they are the same
+        uuid_anon.sort_index(axis=1, inplace=True)
+        xA.anon_df.sort_index(axis=1, inplace=True)
+
+        # there is a quirk of how int is cast on Windows and Unix: int32 vs int64
+        # see SO answer:
+        # Why do Pandas integer `dtypes` not behave the same on Unix and Windows?
+        assert_frame_equal(
+            left=uuid_anon,
+            right=xA.anon_df,
+            check_exact=False,
+            check_dtype=False,
+            check_categorical=False
+        )
+
+if __name__ == "__main__" and __package__ is None:
+    #overwrite __package__ builtin as per PEP 366
+    __package__ = "exhibit"
+    unittest.main(warnings="ignore")
diff --git a/exhibit/core/tests/test_spec.py b/exhibit/core/tests/test_spec.py
index f5230d7..1a3a72a 100644
--- a/exhibit/core/tests/test_spec.py
+++ b/exhibit/core/tests/test_spec.py
@@ -69,7 +69,7 @@ def test_column_order_in_spec_is_correctly_based_on_types(self):
             "ints"  : range(5),
             "floats": np.linspace(0, 1, num=5),
             "bools" : [True, True, True, True, False],
-            "dates" : pd.date_range(start="1/1/2018", periods=5, freq="M"),
+            "dates" : pd.date_range(start="1/1/2018", periods=5, freq="ME"),
             "cats"  : list("ABCDE")
         })
 
diff --git a/exhibit/core/tests/test_utils.py b/exhibit/core/tests/test_utils.py
index 4e7f1b3..52585a0 100644
--- a/exhibit/core/tests/test_utils.py
+++ b/exhibit/core/tests/test_utils.py
@@ -87,13 +87,13 @@ def test_date_frequency_guesser(self):
         returns correct values.
         '''
         
-        test_frequencies = ["D", "M", "MS", "Q", "QS", "BA-MAR"]
+        test_frequencies = ["D", "ME", "MS", "QE", "QS", "BYE-MAR"]
         test_cases = [pd.Series(pd.date_range(start="2015/01/01", periods=12, freq=f))
                       for f in test_frequencies]
 
         result = [tm.guess_date_frequency(x) for x in test_cases]
 
-        expected = ["D", "M", "MS", "Q", "QS", "YS"]
+        expected = ["D", "ME", "MS", "QE", "QS", "YS"]
 
         self.assertEqual(result, expected)
 
@@ -154,7 +154,8 @@ def test_float_or_int(self):
         '''
 
         test_series_1 = pd.Series([1, 2, 3, 4, 5, 0.0])
-        test_series_2 = pd.Series([1, pd.NA, 2, 3])
+        # default dtype for the below range is object rather than int64
+        test_series_2 = pd.Series([1, pd.NA, 2, 3], dtype="Int64")
         test_series_3 = pd.Series([0.1, 0.2, 3, 4])
 
         self.assertTrue(tm.float_or_int(test_series_1), "integer")