A bibliographic catalogue can be studied statistically: which kinds of subject access are present, how many subject headings are assigned to individual records, and how often do those headings carry machine-readable authority identifiers? These are descriptive questions about records and field instances, not questions about XML parsing. They also have practical uses in catalogue assessment and in identifying records for closer examination.
This vignette develops a small case study using genuine MARCXML data. It focuses on a finding that merits investigation: the occurrence of an authority identifier in topical subject fields varies strongly with the number of subject subdivisions. We do not treat the absence of an identifier as proof of a cataloguing error. The package README introduces MARCXML, the canonical representation, editing and reconstruction; those examples are deliberately not repeated here.
The analysis uses 1,000 genuine bibliographic records sampled from the GPO collection and included with the package as a fixed MARCXML file. The source is the U.S. Government Publishing Office’s (GPO) Catalog of U.S. Government Publications. The frozen source corpus and reproducibility material associated with the package evaluation are documented on Zenodo.
The sample represents only part 00, not the complete GPO catalogue. Its records predominantly describe publications from the 1970s. Statistics below refer to the sample as observed and are not estimates of the whole GPO catalogue or of changes through time. This example file is included in the package so the vignette can render without a network connection. The XML data are real; any interpretation of their metadata remains conditional on cataloguing practice and context.
library(marcxmlr)
library(dplyr)
xml_file <- system.file(
"extdata", "gpo-cgp-part00-sample.xml",
package = "marcxmlr", mustWork = TRUE
)
marc <- read_marcxml(xml_file)
n_records <- n_distinct(marc$record_id)
field_instances <- marc |>
filter(field_type == "datafield") |>
distinct(record_id, field_order, tag)
overview <- data.frame(
Measure = c("Bibliographic records", "Canonical rows", "Data-field instances"),
Value = c(n_records, nrow(marc), nrow(field_instances))
)
knitr::kable(overview, format.args = list(big.mark = ","))| Measure | Value |
|---|---|
| Bibliographic records | 1,000 |
| Canonical rows | 53,808 |
| Data-field instances | 24,734 |
The canonical format stores one row per subfield occurrence in a MARC
data field. Thus a field containing $a, $x and
$0 contributes three rows but only one field
instance, identified by (record_id, field_order).
Likewise, a record containing three 650 fields is
one record but three subject-field instances. We will
make each denominator explicit.
Our first unit is the bibliographic record. We measure the
presence of selected MARC fields without treating any of them as
mandatory for every record. For example, 650 means a
topical subject added entry; other MARC fields can also represent
subject access.
tags_of_interest <- data.frame(
tag = c("245", "650", "651", "655", "710", "856"),
meaning = c(
"Title statement", "Topical subject", "Geographic subject",
"Genre/form term", "Corporate added entry", "Electronic location/access"
)
)
coverage <- field_instances |>
filter(tag %in% tags_of_interest$tag) |>
distinct(record_id, tag) |>
count(tag, name = "records") |>
right_join(tags_of_interest, by = "tag") |>
mutate(
records = coalesce(records, 0L),
percent = 100 * records / n_records
) |>
select(tag, meaning, records, percent)
knitr::kable(
coverage, digits = 1,
caption = "Record-level field coverage (denominator: all 1,000 records)."
)| tag | meaning | records | percent |
|---|---|---|---|
| 245 | Title statement | 1000 | 100.0 |
| 650 | Topical subject | 865 | 86.5 |
| 651 | Geographic subject | 123 | 12.3 |
| 655 | Genre/form term | 117 | 11.7 |
| 710 | Corporate added entry | 451 | 45.1 |
| 856 | Electronic location/access | 4 | 0.4 |
The observed sample has 1,000 records with a 245 title
field and 865 with at least one 650 field.
Only four have an 856 electronic-access field. The latter
is not automatically a deficiency: the sample mainly concerns historical
printed government publications. The coverage table measures
presence, not adequacy or correctness.
Next we count field instances within each record.
Critically, the calculation begins with all records,
including those with no 650: a summary calculated only from
650 rows would omit zero-subject records and inflate the
average.
subject_counts <- field_instances |>
filter(tag == "650") |>
count(record_id, name = "n_650") |>
right_join(
data.frame(record_id = unique(marc$record_id)),
by = "record_id"
) |>
mutate(n_650 = coalesce(n_650, 0L)) |>
arrange(record_id)
subject_summary <- data.frame(
Measure = c(
"Records with no 650", "Mean fields per record",
"Median fields per record", "90th percentile", "Maximum"
),
Value = c(
sum(subject_counts$n_650 == 0L), mean(subject_counts$n_650),
median(subject_counts$n_650),
as.numeric(quantile(subject_counts$n_650, 0.90)),
max(subject_counts$n_650)
)
)
subject_summary$Value <- c(
sprintf("%.0f", subject_summary$Value[1]),
sprintf("%.2f", subject_summary$Value[2]),
sprintf("%.0f", subject_summary$Value[3:5])
)
knitr::kable(subject_summary)| Measure | Value |
|---|---|
| Records with no 650 | 135 |
| Mean fields per record | 1.77 |
| Median fields per record | 2 |
| 90th percentile | 3 |
| Maximum | 14 |
subjects_binned <- factor(
pmin(subject_counts$n_650, 5L),
levels = 0:5,
labels = c("0", "1", "2", "3", "4", "5+")
)
barplot(
table(subjects_binned),
xlab = "Number of 650 fields in a record",
ylab = "Number of records",
col = "grey70", border = "grey35"
)Topical subject fields per record. The last category combines records with five or more 650 fields.
The distribution is not captured by a single average: 135
records have no 650, the median is two fields per
record, and the maximum is 14. Those figures are useful as descriptions
of indexing intensity; they are not thresholds for correct
cataloguing.
$0A topical 650 heading can be subdivided by form
($v), topic ($x), period ($y) or
place ($z). Are authority identifiers distributed similarly
among simple and subdivided topical headings?
Each row in topical_fields below represents one
complete 650 field; n_subdivisions
counts occurrences of $v, $x, $y
and $z in that field. This is not a count of words or of
distinct subdivision types.
topical_fields <- field_profiles |>
filter(tag == "650") |>
mutate(
subdivision_group = factor(
case_when(
n_subdivisions == 0L ~ "None",
n_subdivisions == 1L ~ "One",
TRUE ~ "Two or more"
),
levels = c("None", "One", "Two or more")
)
)
subdivision_profile <- topical_fields |>
group_by(subdivision_group) |>
summarise(
field_instances = n(),
with_0 = sum(has_0),
percent_with_0 = 100 * mean(has_0),
.groups = "drop"
)
knitr::kable(
subdivision_profile, digits = 1,
caption = "Topical 650 fields by number of subdivisions and presence of $0."
)| subdivision_group | field_instances | with_0 | percent_with_0 |
|---|---|---|---|
| None | 509 | 484 | 95.1 |
| One | 761 | 254 | 33.4 |
| Two or more | 501 | 23 | 4.6 |
barplot(
subdivision_profile$percent_with_0,
names.arg = subdivision_profile$subdivision_group,
ylim = c(0, 100),
ylab = "650 field instances with nonempty $0 (%)",
xlab = "Number of subject subdivisions",
col = "grey70", border = "grey35"
)Authority identifier presence by subdivision complexity, among 650 field instances.
The difference is pronounced: 484 of 509
650 fields without a subdivision contain $0
(95.1%); the figure falls to 254 of 761
(33.4%) for one subdivision and 23 of 501
(4.6%) for two or more. This is an association within the
observed fields, not evidence that adding subdivisions causes
identifiers to disappear. In particular, an authority number may
identify an established heading, while a string of subdivisions may
require different treatment.
MARC field 650 uses its second
indicator to identify the subject heading system: for example,
0 denotes Library of Congress Subject Headings, while
7 denotes a source named in $2. Since the
cataloguing conventions may differ by system, a pooled percentage can
conceal distinct patterns. The Library of
Congress 650 documentation describes these indicators and
subfields.
vocabulary_profile <- topical_fields |>
group_by(ind2) |>
summarise(
field_instances = n(),
with_0 = sum(has_0),
percent_with_0 = 100 * mean(has_0),
.groups = "drop"
) |>
arrange(desc(field_instances))
knitr::kable(
vocabulary_profile, digits = 1,
caption = "Topical 650 fields by second indicator (subject-heading system)."
)| ind2 | field_instances | with_0 | percent_with_0 |
|---|---|---|---|
| 0 | 1631 | 623 | 38.2 |
| 7 | 121 | 119 | 98.3 |
| 2 | 15 | 15 | 100.0 |
| 6 | 4 | 4 | 100.0 |
lcsh_profile <- topical_fields |>
filter(ind2 == "0") |>
group_by(subdivision_group) |>
summarise(
field_instances = n(),
with_0 = sum(has_0),
percent_with_0 = 100 * mean(has_0),
.groups = "drop"
)
knitr::kable(
lcsh_profile, digits = 1,
caption = "Subdivision association restricted to 650 fields with second indicator 0."
)| subdivision_group | field_instances | with_0 | percent_with_0 |
|---|---|---|---|
| None | 406 | 383 | 94.3 |
| One | 728 | 221 | 30.4 |
| Two or more | 497 | 19 | 3.8 |
The strong gradient remains within second-indicator 0:
383/406 (94.3%) with no subdivision, 221/728
(30.4%) with one and 19/497 (3.8%) with two or
more. This descriptive check rules out the simple explanation
that the overall gradient arises only because different subject heading
systems were pooled. It does not exclude other sources of confounding,
and fields within a record are not independent observations.
A statistical anomaly is useful when it leads to a transparent,
reproducible list for human inspection. For example, we can retrieve
highly subdivided 650 fields without $0,
together with their original record control number and title. They are
candidates for examination, not automatically
errors.
control_numbers <- marc |>
filter(field_type == "controlfield", tag == "001") |>
transmute(record_id, control_001 = value)
titles <- marc |>
filter(tag == "245", subfield_code == "a") |>
group_by(record_id) |>
summarise(title = first(value), .groups = "drop")
# Retain each subdivision's original value for human interpretation.
subject_text <- marc |>
filter(tag == "650", subfield_code %in% c("a", "v", "x", "y", "z")) |>
arrange(record_id, field_order, subfield_order) |>
group_by(record_id, field_order) |>
summarise(heading = paste(value, collapse = " -- "), .groups = "drop")
inspection <- topical_fields |>
filter(n_subdivisions >= 2L, !has_0) |>
arrange(desc(n_subdivisions), record_id, field_order) |>
slice_head(n = 8L) |>
left_join(control_numbers, by = "record_id") |>
left_join(titles, by = "record_id") |>
left_join(subject_text, by = c("record_id", "field_order")) |>
select(control_001, title, heading, n_subdivisions)
knitr::kable(
inspection, caption = "Illustrative 650 fields for inspection (not a list of errors)."
)| control_001 | title | heading | n_subdivisions |
|---|---|---|---|
| 000048475 | Wage statistics of class I railroads in the United States. | Railroads – Employees – Pensions – United States – Statistics – Periodicals. | 5 |
| 000033425 | Symposium on Particulate Control in Energy Processes / | Air – Pollution – United States – Equipment and supplies – Congresses. | 4 |
| 000038455 | Area wage survey. | Wages – Massachusetts – Warwick – Statistics – Periodicals. | 4 |
| 000038455 | Area wage survey. | Wages – Rhode Island – Pawtucket Metropolitan Area – Statistics – Periodicals. | 4 |
| 000038455 | Area wage survey. | Wages – Rhode Island – Providence Metropolitan Area – Statistics – Periodicals. | 4 |
| 000038461 | Area wage survey. | Wages – Ohio – Canton Metropolitan Area – Statistics – Periodicals. | 4 |
| 000038476 | Area wage survey. | Wages – Tennessee – Chattanooga Metropolitan Area – Statistics – Periodicals. | 4 |
| 000000659 | Directory, Federal, State, county, and city government consumer offices. | Consumer education – United States – Information services – Directories. | 3 |
The original marc table remains available for inspecting
indicators, additional subfields and neighbouring fields. This is a
reason to retain the canonical representation until the analytical
question is settled: flattening all subjects into a single record-level
string would lose the association between a particular subdivided
heading and its $0.
The sample is intentionally small enough for the vignette to build
during package checks. To repeat the analysis on a full MARCXML file,
point read_marcxml() at that file if the resulting table
fits comfortably in R. For a larger collection,
marcxml_to_parquet() stores the same canonical columns on
disk. The following code is not executed by this
vignette; it requires local GPO files and the optional
arrow package.
input_dir <- "/path/to/benchmark-inputs"
source_files <- sort(Sys.glob(file.path(
input_dir, "cataloging-records-all-cgp-XML-*.xml"
)))
marcxml_to_parquet(
source_files,
output_dir = "gpo-canonical-parquet",
batch_records = 5000L,
workers = 4L
)
ds <- arrow::open_dataset("gpo-canonical-parquet")
# Field-instance counts, not canonical-row counts.
by_vocabulary <- ds |>
filter(tag == "650") |>
distinct(record_id, field_order, ind2) |>
count(ind2, name = "field_instances") |>
collect()
with_id <- ds |>
filter(tag == "650", subfield_code == "0",
!is.na(value), value != "") |>
distinct(record_id, field_order, ind2) |>
count(ind2, name = "with_0") |>
collect()
left_join(by_vocabulary, with_id, by = "ind2")A study of the full corpus would need to document which source files
were included, any malformed records excluded, the date of the extract,
and the sampling frame. It would also be worthwhile to stratify by
cataloguing agency, type of material, date of record modification, or
other defensible covariates before interpreting differences in authority
linking. A publication’s date in 008 should
not be mistaken for the date when its metadata were
created or enhanced.
This example illustrates three levels of statistical description
enabled by the same canonical MARCXML representation: record-level
coverage, the per-record distribution of repeated fields, and
conditional frequencies at the field-instance level. The final
association between $0 and subject subdivisions would be
easy to mismeasure if subject field boundaries were lost. It remains a
descriptive observation requiring bibliographic
interpretation, not a cataloguing compliance score or a claim
of causality.
$0