layeredcompmodel 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- layeredcompmodel-0.1.0/.github/workflows/ci.yml +25 -0
- layeredcompmodel-0.1.0/.gitignore +134 -0
- layeredcompmodel-0.1.0/CHANGELOG.md +10 -0
- layeredcompmodel-0.1.0/LICENSE +21 -0
- layeredcompmodel-0.1.0/MODEL_SPEC.md +48 -0
- layeredcompmodel-0.1.0/PKG-INFO +176 -0
- layeredcompmodel-0.1.0/README.md +120 -0
- layeredcompmodel-0.1.0/SPEC.md +76 -0
- layeredcompmodel-0.1.0/examples/quickstart.py +28 -0
- layeredcompmodel-0.1.0/pyproject.toml +72 -0
- layeredcompmodel-0.1.0/src/layeredcompmodel/__init__.py +4 -0
- layeredcompmodel-0.1.0/src/layeredcompmodel/model.py +639 -0
- layeredcompmodel-0.1.0/src/layeredcompmodel/py.typed +0 -0
- layeredcompmodel-0.1.0/tests/test_model.py +310 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
strategy:
|
|
9
|
+
matrix:
|
|
10
|
+
python-version: ["3.10", "3.12"]
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
14
|
+
uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: ${{ matrix.python-version }}
|
|
17
|
+
- name: Install & Test
|
|
18
|
+
run: |
|
|
19
|
+
pip install -e .[dev]
|
|
20
|
+
pytest tests/ --cov=layeredcompmodel --cov-report=term-missing --cov-fail-under=75 -ra
|
|
21
|
+
mypy src/layeredcompmodel
|
|
22
|
+
ruff check src/ tests/
|
|
23
|
+
python -m build
|
|
24
|
+
pip install twine
|
|
25
|
+
twine check dist/*
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.PyInstaller/
|
|
11
|
+
result/
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
|
|
28
|
+
# PyInstaller
|
|
29
|
+
# Usually these files are covered by other .gitignore patterns already,
|
|
30
|
+
# but this one is useful for ensuring that the deployment directory is available
|
|
31
|
+
.PyInstaller/
|
|
32
|
+
build/
|
|
33
|
+
develop-eggs/
|
|
34
|
+
dist/
|
|
35
|
+
downloads/
|
|
36
|
+
eggs/
|
|
37
|
+
.eggs/
|
|
38
|
+
lib/
|
|
39
|
+
lib64/
|
|
40
|
+
parts/
|
|
41
|
+
sdist/
|
|
42
|
+
var/
|
|
43
|
+
wheels/
|
|
44
|
+
*.egg-info/
|
|
45
|
+
.installed.cfg
|
|
46
|
+
*.egg
|
|
47
|
+
|
|
48
|
+
# Unit test / coverage reports
|
|
49
|
+
htmlcov/
|
|
50
|
+
.tox/
|
|
51
|
+
.nox/
|
|
52
|
+
.coverage
|
|
53
|
+
.coverage.*
|
|
54
|
+
.cache
|
|
55
|
+
nosetests.xml
|
|
56
|
+
coverage.xml
|
|
57
|
+
*.cover
|
|
58
|
+
.pytest_cache/
|
|
59
|
+
hypothesis/
|
|
60
|
+
|
|
61
|
+
# Translations
|
|
62
|
+
*.mo
|
|
63
|
+
*.pot
|
|
64
|
+
|
|
65
|
+
# Django stuff:
|
|
66
|
+
*.log
|
|
67
|
+
local_settings.py
|
|
68
|
+
db.sqlite3
|
|
69
|
+
db.sqlite3-journal
|
|
70
|
+
|
|
71
|
+
# Flask stuff:
|
|
72
|
+
instance/
|
|
73
|
+
.webassets-cache
|
|
74
|
+
|
|
75
|
+
# Scrapy stuff:
|
|
76
|
+
.scrapy
|
|
77
|
+
|
|
78
|
+
# Sphinx documentation
|
|
79
|
+
docs/_build/
|
|
80
|
+
|
|
81
|
+
# PyBuilder
|
|
82
|
+
target/
|
|
83
|
+
|
|
84
|
+
# Jupyter Notebook
|
|
85
|
+
.ipynb_checkpoints
|
|
86
|
+
|
|
87
|
+
# pyenv
|
|
88
|
+
.python-version
|
|
89
|
+
|
|
90
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
91
|
+
__pypackages__/
|
|
92
|
+
|
|
93
|
+
# Celery stuff
|
|
94
|
+
celerybeat-schedule
|
|
95
|
+
celerybeat.pid
|
|
96
|
+
|
|
97
|
+
# SageMath parsed files
|
|
98
|
+
*.sage.py
|
|
99
|
+
|
|
100
|
+
# Environments
|
|
101
|
+
.venv/
|
|
102
|
+
env/
|
|
103
|
+
ENV/
|
|
104
|
+
env.bak/
|
|
105
|
+
venv/
|
|
106
|
+
ENV/
|
|
107
|
+
env.bak/
|
|
108
|
+
|
|
109
|
+
# Spyder project settings
|
|
110
|
+
.spyderproject
|
|
111
|
+
.spyproject
|
|
112
|
+
|
|
113
|
+
# Rope project settings
|
|
114
|
+
.ropeproject
|
|
115
|
+
|
|
116
|
+
# mkdocs documentation
|
|
117
|
+
/site
|
|
118
|
+
|
|
119
|
+
# mypy
|
|
120
|
+
.mypy_cache/
|
|
121
|
+
.dmypy.json
|
|
122
|
+
dmypy.json
|
|
123
|
+
|
|
124
|
+
# Pyre type checker
|
|
125
|
+
.pyre/
|
|
126
|
+
|
|
127
|
+
# VSCode
|
|
128
|
+
.vscode/
|
|
129
|
+
|
|
130
|
+
# JetBrains IDEs
|
|
131
|
+
.idea/
|
|
132
|
+
|
|
133
|
+
# uv
|
|
134
|
+
uv.lock
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-04-22
|
|
4
|
+
### Added
|
|
5
|
+
- Initial release: Hierarchical tree-based regressor using path-weighted Wilson means (95% trimmed) for robust predictions (e.g., parcel sale prices).
|
|
6
|
+
- NaN handling: Categorical NaNs as distinct "NaN" category (`fillna("NaN").unique()`); numeric NaNs excluded from splits via `notna()` masks (per SPEC.md); target `y` must be finite (raises `ValueError`).
|
|
7
|
+
- Scikit-learn compliance: `BaseEstimator`/`RegressorMixin`; works with `Pipeline`, `GridSearchCV`, `cross_val_score`, pickling; partial `check_estimator` pass (intentional NaN trade-off).
|
|
8
|
+
- Development: Full type hints (`py.typed`, mypy-ready), 16+ unittest/pytest tests (splits/NaN/explain/pickle/sklearn), `examples/quickstart.py` (MAE ~127k), `src/` layout, Hatchling build, dev deps (ruff/black/mypy).
|
|
9
|
+
|
|
10
|
+
Future releases will include Sphinx docs, benchmarks (vs XGBoost/LinearR), CI/CD.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Layered Comp Model
|
|
2
|
+
|
|
3
|
+
# Overview:
|
|
4
|
+
|
|
5
|
+
The idea is to build "hierarchical" predictions that start with a general predicted price, then refine the prediction to be more specific by adding information and narrowing the comparison group.
|
|
6
|
+
|
|
7
|
+
You take the "Wilson mean" of all your parcels, then you find a filter that splits them into the best submarkets you can find and produce a "child node" for each variant of that filter (using a one-vs-rest approach for categorical data). Then you repeat until you've filtered down to a single data point. The value is a weighted average that prioritizes comparing well against closer matches and comparing slightly less well against further matches.
|
|
8
|
+
|
|
9
|
+
To get the predictions back out, you find the most specific bucket your subject matches, then you trace its path up the tree, taking and weighting the Wilson means as you go.
|
|
10
|
+
|
|
11
|
+
# Method:
|
|
12
|
+
|
|
13
|
+
## Training
|
|
14
|
+
|
|
15
|
+
1. Build a tree.
|
|
16
|
+
2. Plot sale prices.
|
|
17
|
+
3. Take the Wilson mean: This is defined as the mean after trimming the top 2.5% and the bottom 2.5% (the middle 95%).
|
|
18
|
+
4. Find a set of filters to use (segmentation score based on weighted MAE reduction (MAE of sale prices vs the mean of the subset)).
|
|
19
|
+
5. Make child nodes (one-vs-rest for categorical, or binary split for numeric using binary search for the breakpoint). We choose the split that results in the lowest ratio of weighted child MAE to parent MAE.
|
|
20
|
+
6. Repeat from step 3 until we've filtered down to a single parcel (leaf node) or cannot split further (minimum node size = 2).
|
|
21
|
+
|
|
22
|
+
## Predicting
|
|
23
|
+
|
|
24
|
+
1. Find the node furthest down in the hierarchy that matches your parcel.
|
|
25
|
+
2. Note its Wilson mean and the Wilson means of all nodes above it in the hierarchy.
|
|
26
|
+
3. Calculate weights for each node in the path:
|
|
27
|
+
- Use the formula $w(x)=(1−x)^{weight\_falloff}$.
|
|
28
|
+
- $x$ is normalized from 0 to 1, evenly spaced by the depth of the node.
|
|
29
|
+
- $x = 0$ for the most specific (leaf) node.
|
|
30
|
+
- $x = 1$ for the root node.
|
|
31
|
+
4. Take the weighted average of the Wilson means.
|
|
32
|
+
5. There's your prediction.
|
|
33
|
+
|
|
34
|
+
# Hyperparameters
|
|
35
|
+
|
|
36
|
+
weight_falloff: 0 to 1. Will be used in w(x)=(1−x)^weight_falloff where x is normalized from 0 to 1
|
|
37
|
+
|
|
38
|
+
# Nuances:
|
|
39
|
+
|
|
40
|
+
The Wilson Mean keeps the prediction from going too crazy on the large sets, and it also penalizes the small sets so when the test set gets specific, the value won't swing wildly.
|
|
41
|
+
|
|
42
|
+
No parcel will get mapped to its own sale price because the weight falloff of the means will add some noise to it, in the direction of the broader market.
|
|
43
|
+
|
|
44
|
+
Every parcel should compare well because this model is fundamentally doing a hierarchical version of comp analysis to determine the value.
|
|
45
|
+
|
|
46
|
+
If a predicted parcel has a feature that wasn't in the training set, that particular level of nuance will be missed, but the parcel will still slot into a node slightly higher up the tree, so the model should still perform reasonably well even for things we don't have representative sales for.
|
|
47
|
+
|
|
48
|
+
The function that the weighted medians follows will determine a lot of how this model handles accuracy vs equity. A fast falloff will give good accuracy but may miss broader market trends. A slow falloff will promote "normativity" in predictions, but may miss market nuance and not assign correct values to particularly rare but valuable features.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: layeredcompmodel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hierarchical tree-based model for robust parcel sale price predictions using weighted Wilson means.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JohnKossa/layeredcompmodel
|
|
6
|
+
Project-URL: Repository, https://github.com/JohnKossa/layeredcompmodel.git
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/JohnKossa/layeredcompmodel/issues
|
|
8
|
+
Author: John Kossa
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Your Name
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: hierarchical-model,real-estate,regression,scikit-learn
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Requires-Dist: numpy>=1.24.0
|
|
44
|
+
Requires-Dist: pandas>=2.0.0
|
|
45
|
+
Requires-Dist: scikit-learn>=1.3.0
|
|
46
|
+
Requires-Dist: scipy>=1.10.0
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: black; extra == 'dev'
|
|
49
|
+
Requires-Dist: build; extra == 'dev'
|
|
50
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
51
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
52
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
53
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
54
|
+
Requires-Dist: scikit-learn[dev]; extra == 'dev'
|
|
55
|
+
Description-Content-Type: text/markdown
|
|
56
|
+
|
|
57
|
+
# LayeredCompModel
|
|
58
|
+
|
|
59
|
+
[](https://pypi.org/project/layeredcompmodel/)
|
|
60
|
+
[](https://layeredcompmodel.readthedocs.io/en/latest/?badge=latest)
|
|
61
|
+
[](https://github.com/JohnKossa/layeredcompmodel/actions)
|
|
62
|
+
[](https://opensource.org/licenses/MIT)
|
|
63
|
+
|
|
64
|
+
Hierarchical tree-based regressor for robust predictions (e.g., parcel sale prices) using path-weighted Wilson means (95% trimmed means for outlier resistance).
|
|
65
|
+
|
|
66
|
+
* [MODEL_SPEC.md](MODEL_SPEC.md): High-level method.
|
|
67
|
+
* [SPEC.md](SPEC.md): Detailed implementation specs.
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
- **Scikit-learn compatible**: Inherits `BaseEstimator`/`RegressorMixin`; works with `Pipeline`, `GridSearchCV`, `cross_val_score`, pickling.
|
|
72
|
+
- **Automatic feature handling**: Categorical (one-vs-rest splits), numeric (binary search breakpoints), NaNs/missing values.
|
|
73
|
+
- **Robust statistics**: Wilson means prevent outlier swings.
|
|
74
|
+
- **Configurable weighting**: `weight_falloff` balances local accuracy vs. market normativity.
|
|
75
|
+
- **Explainable**: `explain_value(row)` shows path, weights, means.
|
|
76
|
+
- **Serializable**: `to_json()`, `to_dict()`.
|
|
77
|
+
- **Parallel**: `n_jobs` support.
|
|
78
|
+
|
|
79
|
+
### NaN Handling
|
|
80
|
+
- **Categorical**: Treated as distinct "NaN" category.
|
|
81
|
+
- **Numeric**: Excluded from splits (robust; per SPEC.md).
|
|
82
|
+
- **Target `y`**: Must be finite (raises `ValueError`).
|
|
83
|
+
- Strict checks: Use `Pipeline([('imputer', SimpleImputer()), ('model', LayeredCompModel())])`.
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install layeredcompmodel
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For development:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git clone https://github.com/JohnKossa/layeredcompmodel.git
|
|
95
|
+
cd layeredcompmodel
|
|
96
|
+
pip install -e .[dev]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Quickstart
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import pandas as pd
|
|
103
|
+
import numpy as np
|
|
104
|
+
from layeredcompmodel import LayeredCompModel
|
|
105
|
+
|
|
106
|
+
# Synthetic real-estate-like data
|
|
107
|
+
rng = np.random.default_rng(42)
|
|
108
|
+
n_samples = 100
|
|
109
|
+
data = {
|
|
110
|
+
'neighborhood': rng.choice(['North', 'South', 'East'], n_samples),
|
|
111
|
+
'size_sqft': rng.normal(2000, 500, n_samples),
|
|
112
|
+
'price': rng.normal(500000, 100000, n_samples) + 100 * rng.normal(0, 1, n_samples) * (rng.normal(0, 1, n_samples) * 2000)
|
|
113
|
+
}
|
|
114
|
+
df = pd.DataFrame(data)
|
|
115
|
+
X = df[['neighborhood', 'size_sqft']]
|
|
116
|
+
y = df['price']
|
|
117
|
+
|
|
118
|
+
# Train
|
|
119
|
+
model = LayeredCompModel(weight_falloff=0.8, n_jobs=1)
|
|
120
|
+
model.fit(X, y)
|
|
121
|
+
|
|
122
|
+
# Predict
|
|
123
|
+
predictions = model.predict(X)
|
|
124
|
+
print(f"Predictions shape: {predictions.shape}")
|
|
125
|
+
print(f"MAE: {np.mean(np.abs(predictions - y)):.0f}")
|
|
126
|
+
|
|
127
|
+
# Explain single prediction
|
|
128
|
+
explanation = model.explain_value(X.iloc[0:1].squeeze())
|
|
129
|
+
print(explanation)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## API Reference
|
|
133
|
+
|
|
134
|
+
### LayeredCompModel(weight_falloff=0.5, split_metric='mae', n_jobs=1)
|
|
135
|
+
|
|
136
|
+
- `fit(X, y)`: Build tree from features `X` (DataFrame), target `y` (Series).
|
|
137
|
+
- `predict(X)`: Predict using path-weighted means.
|
|
138
|
+
- `explain_value(row)`: Dict with path nodes, depths, weights, wilson_means.
|
|
139
|
+
- `to_json(indent=4)`: JSON tree dump.
|
|
140
|
+
- `tree_`: Root `CompNode` (filter_col, filter_val, wilson_mean, children).
|
|
141
|
+
|
|
142
|
+
See [docs](https://layeredcompmodel.readthedocs.io) (TBD).
|
|
143
|
+
|
|
144
|
+
## Examples
|
|
145
|
+
|
|
146
|
+
See [`examples/quickstart.py`](examples/quickstart.py) for a runnable example (code matches Quickstart above).
|
|
147
|
+
|
|
148
|
+
**Run it:**
|
|
149
|
+
```bash
|
|
150
|
+
python examples/quickstart.py
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Expected output:**
|
|
154
|
+
```
|
|
155
|
+
Predictions shape: (100,)
|
|
156
|
+
MAE: 126914
|
|
157
|
+
{'final_prediction': 530354.0426294187, 'weight_falloff': 0.8, 'path': [{'depth': 0, 'wilson_mean': 476353.91361128056, 'count': 100, 'is_leaf': False, 'filter_col': 'size_sqft', 'filter_val': 2101.366485546922}, {'depth': 1, 'wilson_mean': 553953.0606894617, 'count': 42, 'is_leaf': False, 'filter_col': 'neighborhood', 'filter_val': 'North'}, {'depth': 2, 'wilson_mean': 525096.3185716979, 'count': 13, 'is_leaf': True}], 'calculation': '0.199*476354 + 0.512*553953 + 0.289*525096 = 530354'}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Development & Testing
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pytest tests/ --cov=layeredcompmodel
|
|
164
|
+
black src/
|
|
165
|
+
mypy src/
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
CI/CD, Sphinx docs: planned.
|
|
169
|
+
|
|
170
|
+
## Citing
|
|
171
|
+
|
|
172
|
+
Kossa, J. (2026). LayeredCompModel. GitHub. https://github.com/JohnKossa/layeredcompmodel
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# LayeredCompModel
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/layeredcompmodel/)
|
|
4
|
+
[](https://layeredcompmodel.readthedocs.io/en/latest/?badge=latest)
|
|
5
|
+
[](https://github.com/JohnKossa/layeredcompmodel/actions)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
Hierarchical tree-based regressor for robust predictions (e.g., parcel sale prices) using path-weighted Wilson means (95% trimmed means for outlier resistance).
|
|
9
|
+
|
|
10
|
+
* [MODEL_SPEC.md](MODEL_SPEC.md): High-level method.
|
|
11
|
+
* [SPEC.md](SPEC.md): Detailed implementation specs.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Scikit-learn compatible**: Inherits `BaseEstimator`/`RegressorMixin`; works with `Pipeline`, `GridSearchCV`, `cross_val_score`, pickling.
|
|
16
|
+
- **Automatic feature handling**: Categorical (one-vs-rest splits), numeric (binary search breakpoints), NaNs/missing values.
|
|
17
|
+
- **Robust statistics**: Wilson means prevent outlier swings.
|
|
18
|
+
- **Configurable weighting**: `weight_falloff` balances local accuracy vs. market normativity.
|
|
19
|
+
- **Explainable**: `explain_value(row)` shows path, weights, means.
|
|
20
|
+
- **Serializable**: `to_json()`, `to_dict()`.
|
|
21
|
+
- **Parallel**: `n_jobs` support.
|
|
22
|
+
|
|
23
|
+
### NaN Handling
|
|
24
|
+
- **Categorical**: Treated as distinct "NaN" category.
|
|
25
|
+
- **Numeric**: Excluded from splits (robust; per SPEC.md).
|
|
26
|
+
- **Target `y`**: Must be finite (raises `ValueError`).
|
|
27
|
+
- Strict checks: Use `Pipeline([('imputer', SimpleImputer()), ('model', LayeredCompModel())])`.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install layeredcompmodel
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For development:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/JohnKossa/layeredcompmodel.git
|
|
39
|
+
cd layeredcompmodel
|
|
40
|
+
pip install -e .[dev]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import pandas as pd
|
|
47
|
+
import numpy as np
|
|
48
|
+
from layeredcompmodel import LayeredCompModel
|
|
49
|
+
|
|
50
|
+
# Synthetic real-estate-like data
|
|
51
|
+
rng = np.random.default_rng(42)
|
|
52
|
+
n_samples = 100
|
|
53
|
+
data = {
|
|
54
|
+
'neighborhood': rng.choice(['North', 'South', 'East'], n_samples),
|
|
55
|
+
'size_sqft': rng.normal(2000, 500, n_samples),
|
|
56
|
+
'price': rng.normal(500000, 100000, n_samples) + 100 * rng.normal(0, 1, n_samples) * (rng.normal(0, 1, n_samples) * 2000)
|
|
57
|
+
}
|
|
58
|
+
df = pd.DataFrame(data)
|
|
59
|
+
X = df[['neighborhood', 'size_sqft']]
|
|
60
|
+
y = df['price']
|
|
61
|
+
|
|
62
|
+
# Train
|
|
63
|
+
model = LayeredCompModel(weight_falloff=0.8, n_jobs=1)
|
|
64
|
+
model.fit(X, y)
|
|
65
|
+
|
|
66
|
+
# Predict
|
|
67
|
+
predictions = model.predict(X)
|
|
68
|
+
print(f"Predictions shape: {predictions.shape}")
|
|
69
|
+
print(f"MAE: {np.mean(np.abs(predictions - y)):.0f}")
|
|
70
|
+
|
|
71
|
+
# Explain single prediction
|
|
72
|
+
explanation = model.explain_value(X.iloc[0:1].squeeze())
|
|
73
|
+
print(explanation)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API Reference
|
|
77
|
+
|
|
78
|
+
### LayeredCompModel(weight_falloff=0.5, split_metric='mae', n_jobs=1)
|
|
79
|
+
|
|
80
|
+
- `fit(X, y)`: Build tree from features `X` (DataFrame), target `y` (Series).
|
|
81
|
+
- `predict(X)`: Predict using path-weighted means.
|
|
82
|
+
- `explain_value(row)`: Dict with path nodes, depths, weights, wilson_means.
|
|
83
|
+
- `to_json(indent=4)`: JSON tree dump.
|
|
84
|
+
- `tree_`: Root `CompNode` (filter_col, filter_val, wilson_mean, children).
|
|
85
|
+
|
|
86
|
+
See [docs](https://layeredcompmodel.readthedocs.io) (TBD).
|
|
87
|
+
|
|
88
|
+
## Examples
|
|
89
|
+
|
|
90
|
+
See [`examples/quickstart.py`](examples/quickstart.py) for a runnable example (code matches Quickstart above).
|
|
91
|
+
|
|
92
|
+
**Run it:**
|
|
93
|
+
```bash
|
|
94
|
+
python examples/quickstart.py
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Expected output:**
|
|
98
|
+
```
|
|
99
|
+
Predictions shape: (100,)
|
|
100
|
+
MAE: 126914
|
|
101
|
+
{'final_prediction': 530354.0426294187, 'weight_falloff': 0.8, 'path': [{'depth': 0, 'wilson_mean': 476353.91361128056, 'count': 100, 'is_leaf': False, 'filter_col': 'size_sqft', 'filter_val': 2101.366485546922}, {'depth': 1, 'wilson_mean': 553953.0606894617, 'count': 42, 'is_leaf': False, 'filter_col': 'neighborhood', 'filter_val': 'North'}, {'depth': 2, 'wilson_mean': 525096.3185716979, 'count': 13, 'is_leaf': True}], 'calculation': '0.199*476354 + 0.512*553953 + 0.289*525096 = 530354'}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Development & Testing
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pytest tests/ --cov=layeredcompmodel
|
|
108
|
+
black src/
|
|
109
|
+
mypy src/
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
CI/CD, Sphinx docs: planned.
|
|
113
|
+
|
|
114
|
+
## Citing
|
|
115
|
+
|
|
116
|
+
Kossa, J. (2026). LayeredCompModel. GitHub. https://github.com/JohnKossa/layeredcompmodel
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Goal
|
|
2
|
+
|
|
3
|
+
The goal of the Layered Comp Model is to create a hierarchical prediction system that starts with a general predicted price and refines it by adding information and narrowing the comparison group. This is achieved by building a tree of nodes, each representing a filtered subset of the data, and calculating a weighted average of the Wilson means to produce a final prediction. The model aims to balance accuracy and equity by penalizing large sets and promoting normativity in predictions. The implementation should be a scikit-learn compatible estimator.
|
|
4
|
+
|
|
5
|
+
More details can be found in MODEL_SPEC.md
|
|
6
|
+
|
|
7
|
+
# Tech Stack
|
|
8
|
+
|
|
9
|
+
* pandas
|
|
10
|
+
* python
|
|
11
|
+
* numpy
|
|
12
|
+
* scipy
|
|
13
|
+
* scikit-learn
|
|
14
|
+
|
|
15
|
+
# Key Processes
|
|
16
|
+
|
|
17
|
+
## Training
|
|
18
|
+
|
|
19
|
+
### Accepts
|
|
20
|
+
A pandas dataframe
|
|
21
|
+
|
|
22
|
+
A target prediction field
|
|
23
|
+
|
|
24
|
+
A list of columns to use
|
|
25
|
+
|
|
26
|
+
A list of columns to use
|
|
27
|
+
|
|
28
|
+
### Produces
|
|
29
|
+
|
|
30
|
+
A trained scikit-learn compatible model object (e.g., `LayeredCompModel`) with `fit` and `predict` methods.
|
|
31
|
+
|
|
32
|
+
## Prediction
|
|
33
|
+
|
|
34
|
+
### Accepts
|
|
35
|
+
A pandas dataframe
|
|
36
|
+
|
|
37
|
+
A weight_falloff hyperparameter.
|
|
38
|
+
|
|
39
|
+
### Produces
|
|
40
|
+
|
|
41
|
+
A "prediction" field decorated on the dataframe and returns it.
|
|
42
|
+
|
|
43
|
+
# Variable classification
|
|
44
|
+
|
|
45
|
+
We will need to be able to determine whether a column is numeric or categorical so the correct segmentation test can be applied.
|
|
46
|
+
|
|
47
|
+
# Segmentation Scoring
|
|
48
|
+
|
|
49
|
+
Uses a simple linear regression under the hood to fit sale price vs mean and evaluates the split quality by calculating the reduction in Mean Absolute Error (MAE).
|
|
50
|
+
|
|
51
|
+
1. Calculate the base MAE for the current set of data by fitting a linear regression (sale price vs mean) and calculating the MAE ($MAE_{total}$).
|
|
52
|
+
|
|
53
|
+
## Categorical
|
|
54
|
+
|
|
55
|
+
1. For each variant in the categorical (one-vs-rest), treating missing values as a distinct category:
|
|
56
|
+
1. filter to only that variant, calculate its MAE vs its mean ($MAE_v$), and get its count ($N_v$).
|
|
57
|
+
2. filter to the inverse of that variant, calculate its MAE vs its mean ($MAE_{inv}$), and get its count ($N_{inv}$).
|
|
58
|
+
3. Calculate the weighted MAE for the split: $MAE_{weighted} = (MAE_v \times N_v + MAE_{inv} \times N_{inv}) / N_{total}$.
|
|
59
|
+
4. The segmentation score is the ratio: $Score = MAE_{weighted} / MAE_{total}$.
|
|
60
|
+
2. Find the lowest segmentation score among all variants. In case of ties, choose the variant that splits the count most evenly. If still tied, choose the first one.
|
|
61
|
+
|
|
62
|
+
## Numeric
|
|
63
|
+
1. Exclude NaNs from numeric features during this process.
|
|
64
|
+
2. Set num_iterations to minimum of 10 and log2(current population size).
|
|
65
|
+
3. Perform a binary search for an optimal breakpoint:
|
|
66
|
+
1. Set the initial midpoint to the median of the feature values.
|
|
67
|
+
2. filter to the entries below the midpoint, calculate its MAE ($MAE_{low}$) and count ($N_{low}$).
|
|
68
|
+
3. filter to the entries above the midpoint, calculate its MAE ($MAE_{high}$) and count ($N_{high}$).
|
|
69
|
+
4. Calculate the weighted MAE for the split: $MAE_{weighted} = (MAE_{low} \times N_{low} + MAE_{high} \times N_{high}) / N_{total}$.
|
|
70
|
+
5. The segmentation score is the ratio: $Score = MAE_{weighted} / MAE_{total}$.
|
|
71
|
+
6. Move the midpoint to the side that resulted in a better scoring split (lower ratio) and reform the "above" and "below" subsets for each step of the search.
|
|
72
|
+
4. Return the lowest segmentation score and corresponding midpoint found. In case of ties, choose the split that splits the count most evenly. If still tied, choose the first one.
|
|
73
|
+
|
|
74
|
+
# Node Size Constraints
|
|
75
|
+
|
|
76
|
+
Minimum node size is 2. Do not attempt to split a node if its size is below this threshold.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from layeredcompmodel import LayeredCompModel
|
|
4
|
+
|
|
5
|
+
# Synthetic real-estate-like data
|
|
6
|
+
rng = np.random.default_rng(42)
|
|
7
|
+
n_samples = 100
|
|
8
|
+
data = {
|
|
9
|
+
'neighborhood': rng.choice(['North', 'South', 'East'], n_samples),
|
|
10
|
+
'size_sqft': rng.normal(2000, 500, n_samples),
|
|
11
|
+
'price': rng.normal(500000, 100000, n_samples) + 100 * rng.normal(0, 1, n_samples) * (rng.normal(0, 1, n_samples) * 2000)
|
|
12
|
+
}
|
|
13
|
+
df = pd.DataFrame(data)
|
|
14
|
+
X = df[['neighborhood', 'size_sqft']]
|
|
15
|
+
y = df['price']
|
|
16
|
+
|
|
17
|
+
# Train
|
|
18
|
+
model = LayeredCompModel(weight_falloff=0.8, n_jobs=1)
|
|
19
|
+
model.fit(X, y)
|
|
20
|
+
|
|
21
|
+
# Predict
|
|
22
|
+
predictions = model.predict(X)
|
|
23
|
+
print(f"Predictions shape: {predictions.shape}")
|
|
24
|
+
print(f"MAE: {np.mean(np.abs(predictions - y)):.0f}")
|
|
25
|
+
|
|
26
|
+
# Explain single prediction
|
|
27
|
+
explanation = model.explain_value(X.iloc[0:1].squeeze())
|
|
28
|
+
print(explanation)
|