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. We also show how to retrieve complete records for closer inspection using the same MARC-aware query before or after materialisation. 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"
)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"
)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.
To retrieve complete records containing at least one of the specific field instances identified above, use the field-level results to select record IDs, then join back to the canonical table:
candidate_ids <- topical_fields |>
filter(n_subdivisions >= 2L, !has_0) |>
distinct(record_id)
candidate_records <- marc |>
semi_join(candidate_ids, by = "record_id")
c(records = n_distinct(candidate_records$record_id),
canonical_rows = nrow(candidate_records))
#> records canonical_rows
#> 338 19122This criterion applies to one occurrence of 650
at a time. A selected record may contain other 650
occurrences with $0. This field-specific analysis goes
beyond the simpler record-level predicates supported by
marc_has(); the canonical data remain available for such
questions.
For common record-level selection tasks, the dedicated MARC query
interface avoids this manual grouping and joining. As a separate
example, suppose we want records that contain at least one topical
650 field with a $0 subfield, but do not
contain an 856 electronic-access field.
We can express this without manually grouping the canonical rows by record:
q <- marc_has("650", subfield = "0") & !marc_has("856")
selected_table <- marc_filter_records(marc, q)
query_summary <- data.frame(
Measure = c("Selected records", "Canonical rows retained", "650 field instances retained"),
Value = c(
n_distinct(selected_table$record_id),
nrow(selected_table),
selected_table |>
filter(tag == "650") |>
distinct(record_id, field_order) |>
nrow()
)
)
knitr::kable(query_summary)| Measure | Value |
|---|---|
| Selected records | 441 |
| Canonical rows retained | 25004 |
| 650 field instances retained | 1062 |
In this sample, 441 records match, contributing
25,004 canonical rows and 1,062 topical
650 field instances. All fields of these records
are retained, not just their 650$0 rows. The query
establishes the presence of $0, not the validity
or resolution of an authority identifier. It also does not imply that a
record’s every 650 field contains $0.
The same query can instead be evaluated before materialisation, using the native Expat prefilter to avoid expanding records that do not match. Native prefiltering requires an Expat-enabled build; the vignette runs this check when Expat is available and otherwise reports the optional capability as unavailable:
selected_xml <- tryCatch(
read_marcxml(xml_file, filter = q),
error = function(e) {
if (grepl("requires Expat support at package build time",
conditionMessage(e), fixed = TRUE)) return(NULL)
stop(e)
}
)
if (!is.null(selected_xml)) {
# Compare bibliographic control numbers, not numeric record_id values:
# native prefiltering renumbers selected records consecutively.
control_numbers <- function(x) {
x |>
filter(field_type == "controlfield", tag == "001") |>
pull(value) |>
sort()
}
stopifnot(identical(control_numbers(selected_xml),
control_numbers(selected_table)))
knitr::kable(data.frame(
Method = c("Before materialisation", "After materialisation"),
Records = c(n_distinct(selected_xml$record_id),
n_distinct(selected_table$record_id)),
Rows = c(nrow(selected_xml), nrow(selected_table))
))
} else {
cat("Native XML prefiltering unavailable: this build lacks Expat support.\n")
}| Method | Records | Rows |
|---|---|---|
| Before materialisation | 441 | 25004 |
| After materialisation | 441 | 25004 |
The query means the record contains a matching
650$0 and lacks 856. It does not
establish relationships between subfields in one occurrence of a
repeated 650 field. Those occurrence-specific questions are
better answered from the canonical columns record_id and
field_order, as in the statistical analysis above. Generic
dplyr expressions remain available after materialisation,
but are not accepted as native ingestion filters.
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")For a focused extraction from those same source files, the query defined above can be passed directly to the Parquet streaming workflow. Each file is screened once, and parallel workers are assigned different input files. Alternatively, apply the query lazily to the unfiltered Arrow Dataset. These are alternative ways to obtain complete selected records, not steps that need to be performed together:
# Select during ingestion; do not materialise the full catalogue.
marcxml_to_parquet(
source_files,
output_dir = "gpo-selected-parquet",
filter = q,
batch_records = 5000L,
workers = 4L
)
# Or select complete records from the unfiltered Dataset already on disk.
selected_ds <- marc_filter_records(ds, q)
# selected_ds remains lazy; use dplyr::collect() only if it fits in memory.The earlier catalogue-wide statistics should still use the unfiltered Dataset: filtering before estimating overall coverage or frequencies would change their denominators.
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. A MARC-aware
query offers a complementary way to retrieve complete
records for inspection, either during XML ingestion or from an
existing canonical tibble or Arrow Dataset; more detailed field-level
analysis remains available through ordinary R tools. The observed
association is a descriptive finding requiring bibliographic
interpretation, not a cataloguing compliance score or a claim
of causality.