constructelligence commited on
Commit
eec49a5
·
verified ·
1 Parent(s): c63606d

Bid-item shorthand expansion (abbrev.py) in both predictors + 790-item TxDOT bid-item evaluation

Browse files
Files changed (6) hide show
  1. README.md +43 -3
  2. abbrev.py +46 -0
  3. ensemble/predict_ensemble.py +5 -1
  4. metrics.json +50 -1
  5. predict.py +9 -3
  6. txdot_crosswalk.json +46 -0
README.md CHANGED
@@ -75,6 +75,10 @@ MasterFormat codes without a human choosing from a 171-row list.
75
  > division level. Because the transformer is trained on specification prose, short estimate line items are its
76
  > weak spot; the repo therefore also ships a **transformer + TF-IDF ensemble** (`ensemble/`) that lifts
77
  > line-item top-1 to **70.2 %** and improves manual-chunk and section accuracy; see *Ensemble*.
 
 
 
 
78
 
79
  MasterFormat is a registered trademark of CSI / CSC. This project is independent and not affiliated with or
80
  endorsed by them.
@@ -203,6 +207,39 @@ the weights on the 194-item test itself and does not generalise. The transformer
203
  section *division* accuracy (0.810). A reasonable production split is the transformer for whole sections, the
204
  ensemble for terse line items.
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  ## Calibration and abstention
207
 
208
  The ensemble is already close to calibrated; a single **temperature** `T = 0.90` (fit on `val`) lowers the
@@ -223,8 +260,8 @@ enough to file unattended. Reproduce with `python cloud/stats.py`.
223
 
224
  Further gains are **evidence-bound**, not architecture-bound (see the repo's `IMPROVEMENT-PLAN.md`):
225
 
226
- 1. **Enlarge the eval** (≥1,000 line items, ≥400 sections) so changes are measurable at ±3 pts — currently
227
- line-item changes below ~10 pts cannot be validated.
228
  2. **Real line-item training data** (public bid tabulations, agency item catalogs) — the transformer's
229
  line-item ceiling is a data problem; synthetic augmentation was tested and did not transfer (`mf-0.3`).
230
  3. **Domain-adaptive pretraining** on construction prose beyond UFGS, then retrain.
@@ -261,7 +298,8 @@ Further gains are **evidence-bound**, not architecture-bound (see the repo's `IM
261
  - **Out-of-taxonomy inputs** (sections whose level-2 group is not among the 171) can only be scored at
262
  division level.
263
  - **Short, terse line items are the hardest inputs**; division (2-digit) accuracy is consistently higher than
264
- group (6-digit) accuracy.
 
265
  - **Quantized ONNX drifts.** The int8 export is smaller but less faithful than `mf-0.1`'s; prefer fp32.
266
 
267
  ## Bias, risks and safety
@@ -305,6 +343,8 @@ manual-chunk and whole-section accuracy. Constructelligence's proprietary models
305
  - `onnx/` — ONNX fp32 and int8 exports.
306
  - `ensemble/` — `tfidf.joblib` (word + char n-grams, ~112 MB), `blend.json`, `predict_ensemble.py`: the
307
  transformer + TF-IDF line-item ensemble.
 
 
308
  - `IMPROVEMENT-PLAN.md` — the prioritised roadmap (enlarged eval, data, domain-adaptive pretraining, distillation).
309
  - `CITATION.cff`, `requirements.txt`.
310
 
 
75
  > division level. Because the transformer is trained on specification prose, short estimate line items are its
76
  > weak spot; the repo therefore also ships a **transformer + TF-IDF ensemble** (`ensemble/`) that lifts
77
  > line-item top-1 to **70.2 %** and improves manual-chunk and section accuracy; see *Ensemble*.
78
+ >
79
+ > **Update (Oct 2026): bid-item shorthand.** Both predictors now expand agency bid abbreviations
80
+ > (`CONC BOX CULV` → *concrete box culvert*) before classifying. On **790 real TxDOT bid items** the ensemble
81
+ > goes from **19.9 % to 48.9 %** top-1 with no change on any earlier evaluation; see *Agency bid-item shorthand*.
82
 
83
  MasterFormat is a registered trademark of CSI / CSC. This project is independent and not affiliated with or
84
  endorsed by them.
 
207
  section *division* accuracy (0.810). A reasonable production split is the transformer for whole sections, the
208
  ensemble for terse line items.
209
 
210
+ ## Agency bid-item shorthand
211
+
212
+ Public bid tabulations write items in a terse shorthand the transformer never saw in spec prose:
213
+ `RE PM W/RET REQ TY I (W)6"(SLD)(060MIL)`, `CL C CONC (RAIL FOUNDATION)`, `CONC BOX CULV (5 FT X 2 FT)`. Read
214
+ literally, the model sends `CL B CONC` to *Structured Cabling* and `RE PM` to *Electrical Utilities*.
215
+ `abbrev.py` expands 82 common US bid abbreviations (`CONC`, `PAV`, `CULV`, `REINF`, `ASPH`, `W/`…) before
216
+ scoring — **all-caps tokens only**, so prose is left alone, and the original text is appended so nothing is lost.
217
+ `predict.py` and `ensemble/predict_ensemble.py` do this by default; `--no-expand` turns it off. The dictionary is
218
+ written from general bid-item conventions, not fitted to the evaluation below.
219
+
220
+ **Evaluation: 790 TxDOT bid items.** Real item descriptions from Texas DOT public bid tabulations, labelled by
221
+ `txdot_crosswalk.json` — a table mapping 41 TxDOT standard-specification item numbers to MasterFormat groups
222
+ (17 groups, Divisions 01–03, 26, 31–34). Items that could belong to more than one group (repairs, temporary
223
+ work-zone items, signs/signals, special provisions) are left out. *Capped* scores keep at most 20 rows per
224
+ TxDOT item, so pavement markings (207 rows) cannot dominate. 95 % Wilson intervals.
225
+
226
+ | Scorer | top-1 | top-1, capped (n = 529) | top-3 | division |
227
+ |---|---:|---:|---:|---:|
228
+ | mf-0.2 (transformer), raw text | 0.125 [0.104, 0.150] | 0.161 | 0.239 | 0.225 |
229
+ | mf-0.2, abbreviations expanded | 0.270 [0.240, 0.302] | 0.272 | 0.546 | 0.352 |
230
+ | TF-IDF, raw text | 0.266 [0.236, 0.298] | 0.246 | 0.451 | 0.339 |
231
+ | TF-IDF, expanded | 0.484 [0.449, 0.518] | 0.348 | 0.629 | 0.559 |
232
+ | Ensemble, raw text | 0.199 [0.172, 0.228] | 0.223 [0.190, 0.260] | 0.416 | 0.285 |
233
+ | **Ensemble, expanded (default)** | **0.489 [0.454, 0.523]** | **0.369 [0.329, 0.411]** | **0.671** | **0.570** |
234
+
235
+ On the earlier sets the expansion changes 1 of 194 line items and 39 of 6,890 manual chunks: line items stay
236
+ at 0.702, manual chunks at 0.330, and whole-section top-1 moves 0.620 → 0.627.
237
+
238
+ **Reading it.** The expansion is the largest single gain this model has had on real estimate text, with
239
+ non-overlapping intervals. It is still well below the 70 % on hand-written building line items: highway
240
+ bid items are out of domain for a UFGS-trained model, and these labels come from an item-number crosswalk
241
+ (reviewed, not per-item), covering civil work only.
242
+
243
  ## Calibration and abstention
244
 
245
  The ensemble is already close to calibrated; a single **temperature** `T = 0.90` (fit on `val`) lowers the
 
260
 
261
  Further gains are **evidence-bound**, not architecture-bound (see the repo's `IMPROVEMENT-PLAN.md`):
262
 
263
+ 1. **Enlarge the eval** (≥1,000 line items, ≥400 sections) so changes are measurable at ±3 pts. *Progress:*
264
+ 790 crosswalk-labelled TxDOT bid items (±3.5 pts, civil only); building-estimate items are still 194.
265
  2. **Real line-item training data** (public bid tabulations, agency item catalogs) — the transformer's
266
  line-item ceiling is a data problem; synthetic augmentation was tested and did not transfer (`mf-0.3`).
267
  3. **Domain-adaptive pretraining** on construction prose beyond UFGS, then retrain.
 
298
  - **Out-of-taxonomy inputs** (sections whose level-2 group is not among the 171) can only be scored at
299
  division level.
300
  - **Short, terse line items are the hardest inputs**; division (2-digit) accuracy is consistently higher than
301
+ group (6-digit) accuracy. Agency shorthand is expanded by `abbrev.py`, but abbreviations outside its
302
+ dictionary, and highway work generally, remain weak (48.9 % top-1 on TxDOT bid items).
303
  - **Quantized ONNX drifts.** The int8 export is smaller but less faithful than `mf-0.1`'s; prefer fp32.
304
 
305
  ## Bias, risks and safety
 
343
  - `onnx/` — ONNX fp32 and int8 exports.
344
  - `ensemble/` — `tfidf.joblib` (word + char n-grams, ~112 MB), `blend.json`, `predict_ensemble.py`: the
345
  transformer + TF-IDF line-item ensemble.
346
+ - `abbrev.py` — bid-item abbreviation expansion used by both predictors (`--no-expand` to disable).
347
+ - `txdot_crosswalk.json` — TxDOT item number → MasterFormat group table behind the bid-item evaluation.
348
  - `IMPROVEMENT-PLAN.md` — the prioritised roadmap (enlarged eval, data, domain-adaptive pretraining, distillation).
349
  - `CITATION.cff`, `requirements.txt`.
350
 
abbrev.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Expand standard construction bid-item abbreviations before classification.
2
+
3
+ Agency bid tabulations and estimates write items in a terse shorthand ("CONC BOX CULV (5 FT X 2 FT)",
4
+ "RE PM W/RET REQ TY I") that a model trained on spec prose cannot read. This is a general dictionary of
5
+ common US bid-item abbreviations (DOT and municipal), written from those conventions, not fitted to an eval set.
6
+ Whole ALL-CAPS tokens only; the original text is kept after the expansion so nothing is lost.
7
+ """
8
+ import re
9
+
10
+ ABBREV = {
11
+ "ACP": "asphalt concrete pavement", "AGGR": "aggregate", "AGG": "aggregate", "ASPH": "asphalt",
12
+ "ASM": "assembly", "ASSM": "assembly", "ASSY": "assembly", "ATTEN": "attenuator", "BARR": "barricade",
13
+ "BKFL": "backfill", "BM": "beam", "BRK": "broken line", "BS": "base", "CEM": "cement", "CL": "class",
14
+ "CMP": "corrugated metal pipe", "COMPL": "complete", "CONC": "concrete", "CONDR": "conductor",
15
+ "CONDT": "conduit", "CONST": "construction", "CONT": "control", "CRCP": "continuously reinforced concrete pavement",
16
+ "CULV": "culvert", "CUSH": "cushion", "DEL": "delineator", "DIA": "diameter", "DRV": "driveway",
17
+ "ELEC": "electrical", "EMBK": "embankment", "EXC": "excavation", "EXCAV": "excavation", "EXST": "existing",
18
+ "EXIST": "existing", "FBR": "fiber", "FEN": "fence", "FL": "flexible", "GD": "guard", "GR": "grade",
19
+ "HMA": "hot mix asphalt", "IL": "illumination", "ILL": "illumination", "INSTL": "install", "INST": "install",
20
+ "LT": "light", "MATL": "material", "MLCH": "mulch", "MRK": "marking", "MRKR": "marker", "MTL": "metal",
21
+ "PAV": "pavement", "PVMT": "pavement", "PERM": "permanent", "PM": "pavement marking", "PREFAB": "prefabricated",
22
+ "PRESTR": "prestressed", "RC": "reinforced concrete", "RCP": "reinforced concrete pipe", "RD": "roadway",
23
+ "RDWY": "roadway", "REFL": "reflectorized", "REINF": "reinforced", "REM": "remove", "REMOV": "remove",
24
+ "RET": "retroreflectivity", "ROW": "right of way", "RT": "right", "SEDMT": "sediment", "STAB": "stabilized",
25
+ "STR": "structure", "STRUCT": "structure", "SW": "sidewalk", "SWK": "sidewalk", "TEMP": "temporary",
26
+ "TRT": "treatment", "TREAT": "treatment", "TY": "type", "TRANS": "transition", "VEH": "vehicle",
27
+ "W/": "with ", "WW": "wingwall", "HW": "headwall",
28
+ }
29
+ _TOK = re.compile(r"[Ww]/(?=[A-Za-z ])|[A-Za-z]+")
30
+
31
+
32
+ def expand(text: str) -> str:
33
+ """'CONC BOX CULV (5 FT X 2 FT)' -> 'concrete BOX culvert (5 FT X 2 FT) | CONC BOX CULV (5 FT X 2 FT)'."""
34
+ hit = False
35
+
36
+ def sub(m):
37
+ nonlocal hit
38
+ tok = m.group(0)
39
+ # shorthand is written in capitals: prose words ("row", "cl") are left alone
40
+ w = ABBREV.get(tok.upper()) if tok.upper() == tok else None
41
+ if w:
42
+ hit = True
43
+ return w
44
+ return m.group(0)
45
+ out = _TOK.sub(sub, text)
46
+ return f"{out} | {text}" if hit else text
ensemble/predict_ensemble.py CHANGED
@@ -23,6 +23,8 @@ from pathlib import Path
23
  import numpy as np
24
 
25
  HERE = Path(__file__).resolve().parent
 
 
26
  MODEL = "constructelligence/masterformat-classifier"
27
 
28
  DIVISIONS = {
@@ -95,6 +97,7 @@ def main():
95
  ap.add_argument("--divisions", action="store_true")
96
  ap.add_argument("--file")
97
  ap.add_argument("--json", action="store_true")
 
98
  a = ap.parse_args()
99
 
100
  if a.weight is None:
@@ -113,7 +116,8 @@ def main():
113
 
114
  tr, tf = Transformer(a.model), Tfidf(a.tfidf)
115
  assert tf.classes == list(range(len(tr.labels))), "TF-IDF classes do not align with the transformer labels"
116
- lp = norm(w * tr.logprobs(texts) + (1 - w) * tf.logprobs(texts)) # blend of log-probs, renormalised
 
117
 
118
  results = []
119
  for text, row in zip(texts, lp):
 
23
  import numpy as np
24
 
25
  HERE = Path(__file__).resolve().parent
26
+ sys.path.insert(0, str(HERE.parent))
27
+ from abbrev import expand # noqa: E402 bid-item shorthand ("CONC BOX CULV") -> words the models know
28
  MODEL = "constructelligence/masterformat-classifier"
29
 
30
  DIVISIONS = {
 
97
  ap.add_argument("--divisions", action="store_true")
98
  ap.add_argument("--file")
99
  ap.add_argument("--json", action="store_true")
100
+ ap.add_argument("--no-expand", action="store_true", help="do not expand bid-item abbreviations first")
101
  a = ap.parse_args()
102
 
103
  if a.weight is None:
 
116
 
117
  tr, tf = Transformer(a.model), Tfidf(a.tfidf)
118
  assert tf.classes == list(range(len(tr.labels))), "TF-IDF classes do not align with the transformer labels"
119
+ mt = texts if a.no_expand else [expand(t) for t in texts]
120
+ lp = norm(w * tr.logprobs(mt) + (1 - w) * tf.logprobs(mt)) # blend of log-probs, renormalised
121
 
122
  results = []
123
  for text, row in zip(texts, lp):
metrics.json CHANGED
@@ -21,5 +21,54 @@
21
  "sections": 153,
22
  "top1": 0.6066666666666667,
23
  "division": 0.8104575163398693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
- }
 
21
  "sections": 153,
22
  "top1": 0.6066666666666667,
23
  "division": 0.8104575163398693
24
+ },
25
+ "txdot_bid_items": {
26
+ "_doc": "790 TxDOT bid items labelled via txdot_crosswalk.json; capped = <=20 rows per TxDOT item (n=529); 95% Wilson CI",
27
+ "transformer": {
28
+ "raw": {
29
+ "top1": 0.125,
30
+ "top1_ci": [
31
+ 0.104,
32
+ 0.15
33
+ ],
34
+ "capped_top1": 0.161
35
+ },
36
+ "expanded": {
37
+ "top1": 0.27,
38
+ "top1_ci": [
39
+ 0.24,
40
+ 0.302
41
+ ],
42
+ "capped_top1": 0.272,
43
+ "top3": 0.546,
44
+ "division": 0.352
45
+ }
46
+ },
47
+ "ensemble": {
48
+ "raw": {
49
+ "top1": 0.199,
50
+ "top1_ci": [
51
+ 0.172,
52
+ 0.228
53
+ ],
54
+ "capped_top1": 0.223,
55
+ "top3": 0.416,
56
+ "division": 0.285
57
+ },
58
+ "expanded": {
59
+ "top1": 0.489,
60
+ "top1_ci": [
61
+ 0.454,
62
+ 0.523
63
+ ],
64
+ "capped_top1": 0.369,
65
+ "capped_top1_ci": [
66
+ 0.329,
67
+ 0.411
68
+ ],
69
+ "top3": 0.671,
70
+ "division": 0.57
71
+ }
72
+ }
73
  }
74
+ }
predict.py CHANGED
@@ -17,6 +17,10 @@ Without arguments it classifies one built-in example.
17
  import argparse
18
  import json
19
  import sys
 
 
 
 
20
 
21
  MODEL = "constructelligence/masterformat-classifier"
22
 
@@ -101,9 +105,9 @@ def onnx_scorer(path, top_k):
101
  return score
102
 
103
 
104
- def predict(texts, score, top_k, want_div):
105
  results = []
106
- for text, preds in zip(texts, score(texts)):
107
  pl = []
108
  for p in preds:
109
  code, name = split_label(p["label"])
@@ -124,6 +128,8 @@ def main(argv=None):
124
  ap.add_argument("--divisions", action="store_true", help="also show the level-1 (division) roll-up")
125
  ap.add_argument("--file", help="read newline-separated inputs from a file")
126
  ap.add_argument("--json", action="store_true", help="emit JSON instead of a table")
 
 
127
  a = ap.parse_args(argv)
128
 
129
  texts = list(a.text)
@@ -138,7 +144,7 @@ def main(argv=None):
138
  ap.error("no input text")
139
 
140
  score = onnx_scorer(a.onnx, a.top_k) if a.onnx else hf_scorer(a.model, a.top_k)
141
- results = predict(texts, score, a.top_k, a.divisions)
142
 
143
  if a.json:
144
  json.dump(results, sys.stdout, indent=2, ensure_ascii=False)
 
17
  import argparse
18
  import json
19
  import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ from abbrev import expand # noqa: E402 bid-item shorthand ("CONC BOX CULV") -> words the model knows
24
 
25
  MODEL = "constructelligence/masterformat-classifier"
26
 
 
105
  return score
106
 
107
 
108
+ def predict(texts, score, top_k, want_div, model_texts=None):
109
  results = []
110
+ for text, preds in zip(texts, score(model_texts or texts)):
111
  pl = []
112
  for p in preds:
113
  code, name = split_label(p["label"])
 
128
  ap.add_argument("--divisions", action="store_true", help="also show the level-1 (division) roll-up")
129
  ap.add_argument("--file", help="read newline-separated inputs from a file")
130
  ap.add_argument("--json", action="store_true", help="emit JSON instead of a table")
131
+ ap.add_argument("--no-expand", action="store_true",
132
+ help="do not expand bid-item abbreviations (CONC, PAV, CULV, ...) before classifying")
133
  a = ap.parse_args(argv)
134
 
135
  texts = list(a.text)
 
144
  ap.error("no input text")
145
 
146
  score = onnx_scorer(a.onnx, a.top_k) if a.onnx else hf_scorer(a.model, a.top_k)
147
+ results = predict(texts, score, a.top_k, a.divisions, None if a.no_expand else [expand(t) for t in texts])
148
 
149
  if a.json:
150
  json.dump(results, sys.stdout, indent=2, ensure_ascii=False)
txdot_crosswalk.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_doc": "TxDOT standard-specification item number -> MasterFormat level-2 group (the classifier's 171 classes). Labels for eval/line_items_txdot.tsv come from this table, not from per-item judgement. Only items whose TxDOT title maps to one group without doubt are listed; left out on purpose: repair/maintenance items that could equally sit in a maintenance group (351, 354, 361, 438, 446, 677, 678, 7xx maintenance specs), temporary work-zone items (512, 662), signs and signals (636, 644, 680: MasterFormat 34 40 00 is not one of the 171 classes), 400 (mixes structure excavation with cut-and-restore paving), and every 4-digit special specification (title unknown). Built 2026-10-11; review before relying on it.",
3
+ "items": {
4
+ "100": ["Preparing Right of Way", "31 10 00"],
5
+ "104": ["Removing Concrete", "02 40 00"],
6
+ "105": ["Removing Stabilized Base and Asphalt Pavement", "02 40 00"],
7
+ "110": ["Excavation", "31 20 00"],
8
+ "132": ["Embankment", "31 20 00"],
9
+ "164": ["Seeding for Erosion Control", "32 90 00"],
10
+ "247": ["Flexible Base", "32 10 00"],
11
+ "260": ["Lime Treatment (Road-Mixed)", "31 30 00"],
12
+ "275": ["Cement Treatment (Road-Mixed)", "31 30 00"],
13
+ "316": ["Seal Coat", "32 10 00"],
14
+ "360": ["Concrete Pavement", "32 10 00"],
15
+ "416": ["Drilled Shaft Foundations", "31 60 00"],
16
+ "420": ["Concrete Substructures", "03 30 00"],
17
+ "422": ["Concrete Superstructures", "03 30 00"],
18
+ "425": ["Precast Prestressed Concrete Structural Members", "03 40 00"],
19
+ "429": ["Concrete Structure Repair", "03 00 00"],
20
+ "432": ["Riprap", "31 30 00"],
21
+ "462": ["Concrete Box Culverts and Storm Drains", "33 40 00"],
22
+ "464": ["Reinforced Concrete Pipe", "33 40 00"],
23
+ "465": ["Junction Boxes, Manholes, and Inlets", "33 40 00"],
24
+ "466": ["Headwalls and Wingwalls", "33 40 00"],
25
+ "496": ["Removing Structures", "02 40 00"],
26
+ "500": ["Mobilization", "01 70 00"],
27
+ "502": ["Barricades, Signs, and Traffic Handling", "01 50 00"],
28
+ "506": ["Temporary Erosion, Sedimentation, and Environmental Controls", "31 20 00"],
29
+ "529": ["Concrete Curb, Gutter, and Combined Curb and Gutter", "32 10 00"],
30
+ "530": ["Intersections, Driveways, and Turnouts", "32 10 00"],
31
+ "531": ["Sidewalks", "32 10 00"],
32
+ "540": ["Metal Beam Guard Fence", "34 70 00"],
33
+ "544": ["Guardrail End Treatments", "34 70 00"],
34
+ "545": ["Crash Cushion Attenuators", "34 70 00"],
35
+ "550": ["Chain Link Fence", "32 30 00"],
36
+ "610": ["Roadway Illumination Assemblies", "26 50 00"],
37
+ "618": ["Conduit", "26 00 00"],
38
+ "620": ["Electrical Conductors", "26 00 00"],
39
+ "624": ["Ground Boxes", "26 00 00"],
40
+ "658": ["Delineator and Object Marker Assemblies", "34 70 00"],
41
+ "666": ["Retroreflectorized Pavement Markings", "32 10 00"],
42
+ "668": ["Prefabricated Pavement Markings", "32 10 00"],
43
+ "672": ["Raised Pavement Markers", "32 10 00"],
44
+ "752": ["Tree and Brush Removal", "31 10 00"]
45
+ }
46
+ }