|
//! Quantitative proxy metrics for Japanese cognitive writing rhythm. |
|
//! |
|
//! The metrics describe observable surface signals. They do not determine whether |
|
//! a sentence updates the situation or is good writing; that remains a human |
|
//! annotation task. |
|
|
|
use std::collections::{BTreeMap, BTreeSet}; |
|
use std::io::Read; |
|
|
|
use serde::{Deserialize, Serialize}; |
|
use vibrato::{Dictionary, SystemDictionaryBuilder, Tokenizer}; |
|
|
|
mod corpus; |
|
|
|
pub use corpus::{ |
|
CorpusError, CorpusSource, HumanAnnotation, HumanMetricCorrelation, HumanRhythmError, |
|
HumanRhythmReport, MetricDiagnostic, MetricSelectionError, MetricSelectionReport, |
|
correlate_human_rhythm, extract_passages_from_html, fetch_passages, load_sources, |
|
select_metrics, write_passages, |
|
}; |
|
|
|
const DOCUMENT_SIGNALS: &[&str] = &[ |
|
"本章", |
|
"本節", |
|
"この章", |
|
"この節", |
|
"ここまで", |
|
"次に", |
|
"次は", |
|
"次節", |
|
"以下", |
|
"本稿", |
|
]; |
|
|
|
const HESITATION_SIGNALS: &[&str] = &[ |
|
"かもしれない", |
|
"だろう", |
|
"と思う", |
|
"に違いない", |
|
"とは思う", |
|
"ただ", |
|
"しかし", |
|
"ところが", |
|
]; |
|
|
|
const CONTENT_POS: &[&str] = &["名詞", "動詞", "形容詞", "副詞", "連体詞"]; |
|
const KNOWN_POS: &[&str] = &[ |
|
"名詞", |
|
"動詞", |
|
"形容詞", |
|
"副詞", |
|
"連体詞", |
|
"助詞", |
|
"助動詞", |
|
"接続詞", |
|
"感動詞", |
|
"記号", |
|
"補助記号", |
|
"フィラー", |
|
"空白", |
|
]; |
|
|
|
/// One owned token yielded by a morphological analyzer. |
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)] |
|
pub struct MorphToken { |
|
pub surface: String, |
|
pub pos: String, |
|
} |
|
|
|
impl MorphToken { |
|
#[must_use] |
|
pub fn new(surface: impl Into<String>, pos: impl Into<String>) -> Self { |
|
Self { |
|
surface: surface.into(), |
|
pos: pos.into(), |
|
} |
|
} |
|
} |
|
|
|
/// A sentence and its already-tokenized morphological units. |
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)] |
|
pub struct TokenizedSentence { |
|
pub text: String, |
|
pub tokens: Vec<MorphToken>, |
|
} |
|
|
|
impl TokenizedSentence { |
|
#[must_use] |
|
pub fn new(text: impl Into<String>, tokens: Vec<MorphToken>) -> Self { |
|
Self { |
|
text: text.into(), |
|
tokens, |
|
} |
|
} |
|
} |
|
|
|
/// Boundary between the metric layer and a morphological analyzer. |
|
pub trait MorphologicalAnalyzer { |
|
fn tokenize(&self, text: &str) -> Vec<MorphToken>; |
|
} |
|
|
|
/// `vibrato`-backed implementation of [`MorphologicalAnalyzer`]. |
|
pub struct VibratoMorphAnalyzer { |
|
tokenizer: Tokenizer, |
|
} |
|
|
|
impl VibratoMorphAnalyzer { |
|
#[must_use] |
|
pub const fn from_dictionary(dictionary: Dictionary) -> Self { |
|
Self { |
|
tokenizer: Tokenizer::new(dictionary), |
|
} |
|
} |
|
|
|
/// Loads an uncompressed dictionary produced by `vibrato`. |
|
/// |
|
/// The upstream distributed dictionaries use zstd compression and must be |
|
/// decompressed before calling this method. |
|
pub fn from_dictionary_reader<R: Read>(reader: R) -> vibrato::errors::Result<Self> { |
|
Dictionary::read(reader).map(Self::from_dictionary) |
|
} |
|
|
|
/// Builds a dictionary from the four standard MeCab-format source files. |
|
pub fn from_mecab_readers<S, C, P, U>( |
|
system_lexicon: S, |
|
matrix: C, |
|
char_definition: P, |
|
unknown_definition: U, |
|
) -> vibrato::errors::Result<Self> |
|
where |
|
S: Read, |
|
C: Read, |
|
P: Read, |
|
U: Read, |
|
{ |
|
SystemDictionaryBuilder::from_readers( |
|
system_lexicon, |
|
matrix, |
|
char_definition, |
|
unknown_definition, |
|
) |
|
.map(Self::from_dictionary) |
|
} |
|
} |
|
|
|
impl MorphologicalAnalyzer for VibratoMorphAnalyzer { |
|
fn tokenize(&self, text: &str) -> Vec<MorphToken> { |
|
let mut worker = self.tokenizer.new_worker(); |
|
worker.reset_sentence(text); |
|
worker.tokenize(); |
|
|
|
worker |
|
.token_iter() |
|
.map(|token| MorphToken::new(token.surface(), pos_from_feature(token.feature()))) |
|
.collect() |
|
} |
|
} |
|
|
|
/// Observable values used to make the score auditable. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct RhythmMetrics { |
|
pub sentence_count: usize, |
|
pub token_count: usize, |
|
pub lexical_token_count: usize, |
|
pub content_token_count: usize, |
|
pub content_token_ratio: f64, |
|
pub mean_sentence_tokens: f64, |
|
pub sentence_length_cv: f64, |
|
pub length_contrast_transitions: usize, |
|
pub short_long_short_patterns: usize, |
|
pub comma_count: usize, |
|
pub terminal_punctuation_count: usize, |
|
pub question_count: usize, |
|
pub hesitation_signal_sentences: usize, |
|
pub document_signal_sentences: usize, |
|
pub short_terminal_sentences: usize, |
|
} |
|
|
|
/// Part-of-speech frequencies and within-sentence transition statistics. |
|
/// |
|
/// Punctuation is excluded and transitions never cross a sentence boundary. |
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)] |
|
pub struct PosSequenceMetrics { |
|
pub pos_counts: BTreeMap<String, usize>, |
|
pub pos_bigram_counts: BTreeMap<String, usize>, |
|
pub pos_transition_count: usize, |
|
pub same_pos_transition_count: usize, |
|
/// Share of POS transitions that repeat an already-observed POS bigram. |
|
pub pos_bigram_repetition_rate: f64, |
|
/// Shannon entropy (base 2) of observed POS bigram frequencies. |
|
pub pos_bigram_entropy: f64, |
|
} |
|
|
|
/// Surface-level bunsetsu candidates, not a dependency parse. |
|
/// |
|
/// Candidates end at a particle, auxiliary, conjunction, or punctuation. |
|
/// Adjacent candidate pairs are not dependency edges; an actual parser is |
|
/// required to determine heads and relation labels. |
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)] |
|
pub struct BunsetsuProxyMetrics { |
|
pub bunsetsu_candidate_count: usize, |
|
pub mean_candidate_tokens: f64, |
|
pub candidate_length_cv: f64, |
|
pub adjacent_candidate_pairs: usize, |
|
pub conjunction_token_count: usize, |
|
} |
|
|
|
/// Frequency-domain and serial-correlation summary of a sentence-indexed series. |
|
/// |
|
/// Frequencies are cycles per sentence, not acoustic Hertz. Values are absent |
|
/// for fewer than four samples or for a series without variation. The |
|
/// autocorrelation fields are absent for fewer than eight samples. |
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)] |
|
pub struct FrequencyPattern { |
|
pub sample_count: usize, |
|
pub mean: f64, |
|
pub dominant_frequency_cycles_per_sentence: Option<f64>, |
|
pub dominant_period_sentences: Option<f64>, |
|
pub dominant_power_ratio: Option<f64>, |
|
/// Highest positive Pearson correlation between the series and a lagged copy. |
|
pub max_lag_autocorrelation: Option<f64>, |
|
/// Lag, in sentences, at which `max_lag_autocorrelation` occurs. |
|
pub max_lag_autocorrelation_lag: Option<usize>, |
|
/// One-sided permutation p-value for the maximum lag autocorrelation. |
|
/// |
|
/// The null distribution is formed from 999 deterministic shuffles of the |
|
/// same sentence-length values, each searched over the same lag range. |
|
pub max_lag_autocorrelation_permutation_p_value: Option<f64>, |
|
} |
|
|
|
/// Frequency-domain proxies for prose rhythm and lexical topical movement. |
|
/// |
|
/// `lexical_topic_shift` is the adjacent-sentence Jaccard distance over |
|
/// surface content words. It is not a semantic embedding distance. |
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)] |
|
pub struct FrequencyMetrics { |
|
pub sentence_length: FrequencyPattern, |
|
pub lexical_topic_shift: FrequencyPattern, |
|
} |
|
|
|
/// One observed feature range in a reference writing profile. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct PatternTarget { |
|
pub feature: String, |
|
pub lower_bound: f64, |
|
pub upper_bound: f64, |
|
pub reference_count: usize, |
|
} |
|
|
|
/// A target profile inferred from texts chosen as suitable references. |
|
/// |
|
/// It describes a writing context, not a universal definition of good prose. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct PatternTargetProfile { |
|
pub reference_count: usize, |
|
pub targets: Vec<PatternTarget>, |
|
} |
|
|
|
/// Direction of a candidate's adjustment relative to a target range. |
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)] |
|
#[serde(rename_all = "snake_case")] |
|
pub enum PatternAdjustment { |
|
Increase, |
|
Decrease, |
|
WithinRange, |
|
} |
|
|
|
/// One auditable profile comparison for a candidate text. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct PatternDeviation { |
|
pub feature: String, |
|
pub value: f64, |
|
pub lower_bound: f64, |
|
pub upper_bound: f64, |
|
pub distance: f64, |
|
pub adjustment: PatternAdjustment, |
|
} |
|
|
|
/// Feedback that a generator or human editor can use for another revision. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct PatternCorrection { |
|
pub assessed_feature_count: usize, |
|
/// Mean absolute distance outside reference ranges; lower is closer. |
|
pub mean_distance: f64, |
|
pub deviations: Vec<PatternDeviation>, |
|
} |
|
|
|
/// How one profile feature changed across a revision. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct RevisionFeatureChange { |
|
pub feature: String, |
|
pub before_distance: f64, |
|
pub after_distance: f64, |
|
/// Positive values mean that the revision moved closer to the profile. |
|
pub distance_reduction: f64, |
|
pub before_adjustment: PatternAdjustment, |
|
pub after_adjustment: PatternAdjustment, |
|
} |
|
|
|
/// Auditable before/after evaluation against one reference writing profile. |
|
/// |
|
/// `profile_gap_reduction_score` is the percentage of the initial mean |
|
/// profile distance removed by the revision. It is absent when the original |
|
/// was already in range, and it is not a general writing-quality score. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct RevisionEvaluation { |
|
pub before: Evaluation, |
|
pub after: Evaluation, |
|
pub before_correction: PatternCorrection, |
|
pub after_correction: PatternCorrection, |
|
pub profile_distance_before: f64, |
|
pub profile_distance_after: f64, |
|
pub profile_distance_reduction: f64, |
|
pub profile_gap_reduction_score: Option<f64>, |
|
/// Jaccard similarity of content-word surface-form sets, when the source |
|
/// text contains at least one content word. |
|
pub content_token_retention: Option<f64>, |
|
pub rhythm_score_change: f64, |
|
pub resolved_feature_count: usize, |
|
pub introduced_feature_count: usize, |
|
pub feature_changes: Vec<RevisionFeatureChange>, |
|
} |
|
|
|
impl PatternTargetProfile { |
|
/// Infers feature ranges from selected, genre-matched reference texts. |
|
#[must_use] |
|
pub fn from_references(references: &[Evaluation]) -> Self { |
|
let targets = PATTERN_PROFILE_FEATURES |
|
.iter() |
|
.filter_map(|feature| { |
|
let values = references |
|
.iter() |
|
.filter_map(|evaluation| pattern_profile_value(evaluation, feature)) |
|
.collect::<Vec<_>>(); |
|
let (lower_bound, upper_bound) = profile_interval(&values)?; |
|
Some(PatternTarget { |
|
feature: (*feature).to_owned(), |
|
lower_bound, |
|
upper_bound, |
|
reference_count: values.len(), |
|
}) |
|
}) |
|
.collect(); |
|
Self { |
|
reference_count: references.len(), |
|
targets, |
|
} |
|
} |
|
|
|
/// Reports which observed features lie outside this profile's ranges. |
|
#[must_use] |
|
pub fn correction_for(&self, candidate: &Evaluation) -> PatternCorrection { |
|
let deviations = self |
|
.targets |
|
.iter() |
|
.filter_map(|target| { |
|
let value = pattern_profile_value(candidate, &target.feature)?; |
|
let (distance, adjustment) = if value < target.lower_bound { |
|
(target.lower_bound - value, PatternAdjustment::Increase) |
|
} else if value > target.upper_bound { |
|
(value - target.upper_bound, PatternAdjustment::Decrease) |
|
} else { |
|
(0.0, PatternAdjustment::WithinRange) |
|
}; |
|
Some(PatternDeviation { |
|
feature: target.feature.clone(), |
|
value, |
|
lower_bound: target.lower_bound, |
|
upper_bound: target.upper_bound, |
|
distance, |
|
adjustment, |
|
}) |
|
}) |
|
.collect::<Vec<_>>(); |
|
let assessed_feature_count = deviations.len(); |
|
let mean_distance = if assessed_feature_count == 0 { |
|
0.0 |
|
} else { |
|
deviations.iter().map(|item| item.distance).sum::<f64>() / assessed_feature_count as f64 |
|
}; |
|
PatternCorrection { |
|
assessed_feature_count, |
|
mean_distance, |
|
deviations, |
|
} |
|
} |
|
} |
|
|
|
/// Compares an already-evaluated before/after pair against a reference profile. |
|
/// |
|
/// This deliberately keeps profile alignment and content preservation |
|
/// separate: a rewrite must not receive a higher alignment score merely by |
|
/// changing its subject matter. |
|
#[must_use] |
|
pub fn assess_revision( |
|
profile: &PatternTargetProfile, |
|
before: &Evaluation, |
|
after: &Evaluation, |
|
content_token_retention: Option<f64>, |
|
) -> RevisionEvaluation { |
|
let before_correction = profile.correction_for(before); |
|
let after_correction = profile.correction_for(after); |
|
let after_by_feature = after_correction |
|
.deviations |
|
.iter() |
|
.map(|deviation| (deviation.feature.as_str(), deviation)) |
|
.collect::<BTreeMap<_, _>>(); |
|
let feature_changes = before_correction |
|
.deviations |
|
.iter() |
|
.filter_map(|before_deviation| { |
|
let after_deviation = after_by_feature.get(before_deviation.feature.as_str())?; |
|
Some(RevisionFeatureChange { |
|
feature: before_deviation.feature.clone(), |
|
before_distance: before_deviation.distance, |
|
after_distance: after_deviation.distance, |
|
distance_reduction: before_deviation.distance - after_deviation.distance, |
|
before_adjustment: before_deviation.adjustment.clone(), |
|
after_adjustment: after_deviation.adjustment.clone(), |
|
}) |
|
}) |
|
.collect::<Vec<_>>(); |
|
let resolved_feature_count = feature_changes |
|
.iter() |
|
.filter(|change| { |
|
change.before_distance > f64::EPSILON && change.after_distance <= f64::EPSILON |
|
}) |
|
.count(); |
|
let introduced_feature_count = feature_changes |
|
.iter() |
|
.filter(|change| { |
|
change.before_distance <= f64::EPSILON && change.after_distance > f64::EPSILON |
|
}) |
|
.count(); |
|
let profile_distance_before = before_correction.mean_distance; |
|
let profile_distance_after = after_correction.mean_distance; |
|
let profile_distance_reduction = profile_distance_before - profile_distance_after; |
|
let profile_gap_reduction_score = (profile_distance_before > f64::EPSILON) |
|
.then_some(profile_distance_reduction / profile_distance_before * 100.0); |
|
|
|
RevisionEvaluation { |
|
before: before.clone(), |
|
after: after.clone(), |
|
before_correction, |
|
after_correction, |
|
profile_distance_before, |
|
profile_distance_after, |
|
profile_distance_reduction, |
|
profile_gap_reduction_score, |
|
content_token_retention, |
|
rhythm_score_change: after.rhythm_score - before.rhythm_score, |
|
resolved_feature_count, |
|
introduced_feature_count, |
|
feature_changes, |
|
} |
|
} |
|
|
|
/// A deliberately inspectable heuristic, not a writing-quality classifier. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct Evaluation { |
|
/// 0--100: surface proxy for variation and sentence cadence. |
|
pub rhythm_score: f64, |
|
/// 0--100: rate of document-progress signals that require human review. |
|
pub document_update_risk: f64, |
|
pub metrics: RhythmMetrics, |
|
pub pos_sequence: PosSequenceMetrics, |
|
pub bunsetsu_proxy: BunsetsuProxyMetrics, |
|
pub frequency: FrequencyMetrics, |
|
} |
|
|
|
/// Human-labelled text used to calibrate or falsify the heuristic. |
|
#[derive(Clone, Debug, Deserialize, Serialize)] |
|
pub struct LabeledText { |
|
pub id: String, |
|
pub label: String, |
|
#[serde(default)] |
|
pub source_id: String, |
|
#[serde(default)] |
|
pub author: String, |
|
#[serde(default)] |
|
pub source_url: String, |
|
#[serde(default)] |
|
pub split_group: String, |
|
#[serde(default)] |
|
pub domain: String, |
|
#[serde(default)] |
|
pub source_hash: String, |
|
pub text: String, |
|
} |
|
|
|
/// One labelled text with its proxy metrics. |
|
#[derive(Clone, Debug, Serialize)] |
|
pub struct ScoredText { |
|
pub id: String, |
|
pub label: String, |
|
pub source_id: String, |
|
pub author: String, |
|
pub split_group: String, |
|
pub domain: String, |
|
pub evaluation: Evaluation, |
|
} |
|
|
|
/// Mean scores for a label. A corpus should contain independently annotated |
|
/// documents before these values are interpreted. |
|
#[derive(Clone, Debug, PartialEq, Serialize)] |
|
pub struct LabelSummary { |
|
pub count: usize, |
|
pub mean_rhythm_score: f64, |
|
pub mean_document_update_risk: f64, |
|
} |
|
|
|
#[derive(Clone, Debug, Serialize)] |
|
pub struct CorpusEvaluation { |
|
pub records: Vec<ScoredText>, |
|
pub labels: BTreeMap<String, LabelSummary>, |
|
} |
|
|
|
const PATTERN_PROFILE_FEATURES: &[&str] = &[ |
|
"sentence_length_cv", |
|
"lexical_topic_shift_mean", |
|
"pos_bigram_entropy", |
|
]; |
|
|
|
const ROBUST_PROFILE_MINIMUM_REFERENCES: usize = 10; |
|
const PROFILE_LOWER_QUANTILE: f64 = 0.1; |
|
const PROFILE_UPPER_QUANTILE: f64 = 0.9; |
|
const AUTOCORRELATION_MINIMUM_SAMPLES: usize = 8; |
|
const AUTOCORRELATION_MAXIMUM_LAG: usize = 8; |
|
const AUTOCORRELATION_PERMUTATIONS: usize = 999; |
|
|
|
/// Splits Japanese prose while retaining `。!?!?` on each sentence. |
|
#[must_use] |
|
pub fn split_sentences(text: &str) -> Vec<String> { |
|
let mut sentences = Vec::new(); |
|
let mut current = String::new(); |
|
|
|
for character in text.chars() { |
|
current.push(character); |
|
if is_terminal_punctuation(character) { |
|
push_trimmed_sentence(&mut sentences, &mut current); |
|
} |
|
} |
|
push_trimmed_sentence(&mut sentences, &mut current); |
|
sentences |
|
} |
|
|
|
/// Runs the surface evaluation on sentences whose tokens are already known. |
|
#[must_use] |
|
pub fn evaluate(sentences: &[TokenizedSentence]) -> Evaluation { |
|
let sentence_lengths: Vec<usize> = sentences |
|
.iter() |
|
.map(|sentence| lexical_token_count(&sentence.tokens)) |
|
.collect(); |
|
let sentence_count = sentences.len(); |
|
let token_count = sentences.iter().map(|sentence| sentence.tokens.len()).sum(); |
|
let lexical_token_count = sentences |
|
.iter() |
|
.map(|sentence| lexical_token_count(&sentence.tokens)) |
|
.sum(); |
|
let content_token_count = sentences |
|
.iter() |
|
.flat_map(|sentence| &sentence.tokens) |
|
.filter(|token| is_content_pos(&token.pos)) |
|
.count(); |
|
let mean_sentence_tokens = mean_usize(&sentence_lengths); |
|
let sentence_length_cv = coefficient_of_variation(&sentence_lengths, mean_sentence_tokens); |
|
let length_contrast_transitions = contrast_transitions(&sentence_lengths, mean_sentence_tokens); |
|
let short_long_short_patterns = short_long_short_patterns(&sentence_lengths); |
|
let comma_count = sentences |
|
.iter() |
|
.map(|sentence| count_characters(&sentence.text, &[',', '、', ','])) |
|
.sum(); |
|
let terminal_punctuation_count = sentences |
|
.iter() |
|
.map(|sentence| count_terminal_punctuation(&sentence.text)) |
|
.sum(); |
|
let question_count = sentences |
|
.iter() |
|
.map(|sentence| count_characters(&sentence.text, &['?', '?'])) |
|
.sum(); |
|
let hesitation_signal_sentences = sentences |
|
.iter() |
|
.filter(|sentence| contains_any(&sentence.text, HESITATION_SIGNALS)) |
|
.count(); |
|
let document_signal_sentences = sentences |
|
.iter() |
|
.filter(|sentence| contains_any(&sentence.text, DOCUMENT_SIGNALS)) |
|
.count(); |
|
let short_terminal_sentences = sentences |
|
.iter() |
|
.zip(&sentence_lengths) |
|
.filter(|(sentence, length)| { |
|
**length > 0 |
|
&& (**length as f64) <= mean_sentence_tokens * 0.7 |
|
&& contains_terminal_punctuation(&sentence.text) |
|
}) |
|
.count(); |
|
|
|
let content_token_ratio = if lexical_token_count == 0 { |
|
0.0 |
|
} else { |
|
content_token_count as f64 / lexical_token_count as f64 |
|
}; |
|
let pos_sequence = pos_sequence_metrics(sentences); |
|
let bunsetsu_proxy = bunsetsu_proxy_metrics(sentences); |
|
let frequency = frequency_metrics(sentences, &sentence_lengths); |
|
let metrics = RhythmMetrics { |
|
sentence_count, |
|
token_count, |
|
lexical_token_count, |
|
content_token_count, |
|
content_token_ratio, |
|
mean_sentence_tokens, |
|
sentence_length_cv, |
|
length_contrast_transitions, |
|
short_long_short_patterns, |
|
comma_count, |
|
terminal_punctuation_count, |
|
question_count, |
|
hesitation_signal_sentences, |
|
document_signal_sentences, |
|
short_terminal_sentences, |
|
}; |
|
|
|
Evaluation { |
|
rhythm_score: rhythm_score(&metrics), |
|
document_update_risk: percentage(document_signal_sentences, sentence_count), |
|
metrics, |
|
pos_sequence, |
|
bunsetsu_proxy, |
|
frequency, |
|
} |
|
} |
|
|
|
/// Tokenizes and evaluates a whole text using an injected analyzer. |
|
#[must_use] |
|
pub fn evaluate_text<A: MorphologicalAnalyzer>(analyzer: &A, text: &str) -> Evaluation { |
|
let sentences: Vec<_> = split_sentences(text) |
|
.into_iter() |
|
.map(|sentence| { |
|
let tokens = analyzer.tokenize(&sentence); |
|
TokenizedSentence::new(sentence, tokens) |
|
}) |
|
.collect(); |
|
evaluate(&sentences) |
|
} |
|
|
|
/// Evaluates a textual revision while retaining the surface content-word overlap. |
|
#[must_use] |
|
pub fn evaluate_revision<A: MorphologicalAnalyzer>( |
|
analyzer: &A, |
|
profile: &PatternTargetProfile, |
|
before_text: &str, |
|
after_text: &str, |
|
) -> RevisionEvaluation { |
|
let before = evaluate_text(analyzer, before_text); |
|
let after = evaluate_text(analyzer, after_text); |
|
let content_token_retention = content_token_retention(analyzer, before_text, after_text); |
|
assess_revision(profile, &before, &after, content_token_retention) |
|
} |
|
|
|
/// Scores each labelled example and groups arithmetic means by label. |
|
#[must_use] |
|
pub fn evaluate_corpus<A: MorphologicalAnalyzer>( |
|
analyzer: &A, |
|
corpus: &[LabeledText], |
|
) -> CorpusEvaluation { |
|
let records: Vec<_> = corpus |
|
.iter() |
|
.map(|entry| ScoredText { |
|
id: entry.id.clone(), |
|
label: entry.label.clone(), |
|
source_id: entry.source_id.clone(), |
|
author: entry.author.clone(), |
|
split_group: entry.split_group.clone(), |
|
domain: entry.domain.clone(), |
|
evaluation: evaluate_text(analyzer, &entry.text), |
|
}) |
|
.collect(); |
|
|
|
let mut totals: BTreeMap<String, (usize, f64, f64)> = BTreeMap::new(); |
|
for record in &records { |
|
let total = totals.entry(record.label.clone()).or_default(); |
|
total.0 += 1; |
|
total.1 += record.evaluation.rhythm_score; |
|
total.2 += record.evaluation.document_update_risk; |
|
} |
|
let labels = totals |
|
.into_iter() |
|
.map(|(label, (count, rhythm_total, risk_total))| { |
|
( |
|
label, |
|
LabelSummary { |
|
count, |
|
mean_rhythm_score: rhythm_total / count as f64, |
|
mean_document_update_risk: risk_total / count as f64, |
|
}, |
|
) |
|
}) |
|
.collect(); |
|
|
|
CorpusEvaluation { records, labels } |
|
} |
|
|
|
fn push_trimmed_sentence(sentences: &mut Vec<String>, current: &mut String) { |
|
let trimmed = current.trim(); |
|
if !trimmed.is_empty() { |
|
sentences.push(trimmed.to_owned()); |
|
} |
|
current.clear(); |
|
} |
|
|
|
fn pos_from_feature(feature: &str) -> String { |
|
feature |
|
.split(',') |
|
.find(|item| KNOWN_POS.contains(item)) |
|
.unwrap_or("未知") |
|
.to_owned() |
|
} |
|
|
|
fn is_terminal_punctuation(character: char) -> bool { |
|
matches!(character, '。' | '!' | '?' | '!' | '?') |
|
} |
|
|
|
fn contains_terminal_punctuation(text: &str) -> bool { |
|
text.chars().any(is_terminal_punctuation) |
|
} |
|
|
|
fn count_terminal_punctuation(text: &str) -> usize { |
|
text.chars() |
|
.filter(|character| is_terminal_punctuation(*character)) |
|
.count() |
|
} |
|
|
|
fn count_characters(text: &str, targets: &[char]) -> usize { |
|
text.chars() |
|
.filter(|character| targets.contains(character)) |
|
.count() |
|
} |
|
|
|
fn contains_any(text: &str, markers: &[&str]) -> bool { |
|
markers.iter().any(|marker| text.contains(marker)) |
|
} |
|
|
|
fn is_content_pos(pos: &str) -> bool { |
|
CONTENT_POS.contains(&pos) |
|
} |
|
|
|
fn is_punctuation(token: &MorphToken) -> bool { |
|
token.pos.contains("記号") |
|
|| token.surface.chars().all(|character| { |
|
character.is_ascii_punctuation() || "、。!?「」『』()[]【】".contains(character) |
|
}) |
|
} |
|
|
|
fn lexical_token_count(tokens: &[MorphToken]) -> usize { |
|
tokens.iter().filter(|token| !is_punctuation(token)).count() |
|
} |
|
|
|
fn content_token_set<A: MorphologicalAnalyzer>(analyzer: &A, text: &str) -> BTreeSet<String> { |
|
split_sentences(text) |
|
.into_iter() |
|
.flat_map(|sentence| analyzer.tokenize(&sentence)) |
|
.filter(|token| is_content_pos(&token.pos)) |
|
.map(|token| token.surface.to_lowercase()) |
|
.collect() |
|
} |
|
|
|
fn content_token_retention<A: MorphologicalAnalyzer>( |
|
analyzer: &A, |
|
before_text: &str, |
|
after_text: &str, |
|
) -> Option<f64> { |
|
let before = content_token_set(analyzer, before_text); |
|
if before.is_empty() { |
|
return None; |
|
} |
|
let after = content_token_set(analyzer, after_text); |
|
let union_count = before.union(&after).count(); |
|
Some(before.intersection(&after).count() as f64 / union_count as f64) |
|
} |
|
|
|
fn pos_sequence_metrics(sentences: &[TokenizedSentence]) -> PosSequenceMetrics { |
|
let mut pos_counts = BTreeMap::new(); |
|
let mut pos_bigram_counts = BTreeMap::new(); |
|
let mut pos_transition_count = 0; |
|
let mut same_pos_transition_count = 0; |
|
|
|
for sentence in sentences { |
|
let poses: Vec<_> = sentence |
|
.tokens |
|
.iter() |
|
.filter(|token| !is_punctuation(token)) |
|
.map(|token| token.pos.as_str()) |
|
.collect(); |
|
for pos in &poses { |
|
*pos_counts.entry((*pos).to_owned()).or_default() += 1; |
|
} |
|
for pair in poses.windows(2) { |
|
pos_transition_count += 1; |
|
if pair[0] == pair[1] { |
|
same_pos_transition_count += 1; |
|
} |
|
*pos_bigram_counts |
|
.entry(format!("{}→{}", pair[0], pair[1])) |
|
.or_default() += 1; |
|
} |
|
} |
|
|
|
PosSequenceMetrics { |
|
pos_counts, |
|
pos_bigram_entropy: shannon_entropy(&pos_bigram_counts), |
|
pos_bigram_repetition_rate: if pos_transition_count == 0 { |
|
0.0 |
|
} else { |
|
(pos_transition_count - pos_bigram_counts.len()) as f64 / pos_transition_count as f64 |
|
}, |
|
pos_bigram_counts, |
|
pos_transition_count, |
|
same_pos_transition_count, |
|
} |
|
} |
|
|
|
fn bunsetsu_proxy_metrics(sentences: &[TokenizedSentence]) -> BunsetsuProxyMetrics { |
|
let mut candidate_lengths = Vec::new(); |
|
let mut adjacent_candidate_pairs = 0; |
|
let mut conjunction_token_count = 0; |
|
|
|
for sentence in sentences { |
|
let mut candidate_count = 0; |
|
let mut current_length = 0; |
|
for token in &sentence.tokens { |
|
if token.pos == "接続詞" { |
|
conjunction_token_count += 1; |
|
} |
|
if is_punctuation(token) { |
|
push_bunsetsu_candidate( |
|
&mut candidate_lengths, |
|
&mut current_length, |
|
&mut candidate_count, |
|
); |
|
continue; |
|
} |
|
|
|
current_length += 1; |
|
if ends_bunsetsu_candidate(token) { |
|
push_bunsetsu_candidate( |
|
&mut candidate_lengths, |
|
&mut current_length, |
|
&mut candidate_count, |
|
); |
|
} |
|
} |
|
push_bunsetsu_candidate( |
|
&mut candidate_lengths, |
|
&mut current_length, |
|
&mut candidate_count, |
|
); |
|
adjacent_candidate_pairs += candidate_count.saturating_sub(1); |
|
} |
|
|
|
let mean_candidate_tokens = mean_usize(&candidate_lengths); |
|
BunsetsuProxyMetrics { |
|
bunsetsu_candidate_count: candidate_lengths.len(), |
|
mean_candidate_tokens, |
|
candidate_length_cv: coefficient_of_variation(&candidate_lengths, mean_candidate_tokens), |
|
adjacent_candidate_pairs, |
|
conjunction_token_count, |
|
} |
|
} |
|
|
|
fn frequency_metrics( |
|
sentences: &[TokenizedSentence], |
|
sentence_lengths: &[usize], |
|
) -> FrequencyMetrics { |
|
FrequencyMetrics { |
|
sentence_length: frequency_pattern( |
|
&sentence_lengths |
|
.iter() |
|
.map(|length| *length as f64) |
|
.collect::<Vec<_>>(), |
|
), |
|
lexical_topic_shift: frequency_pattern(&lexical_topic_shift_series(sentences)), |
|
} |
|
} |
|
|
|
fn lexical_topic_shift_series(sentences: &[TokenizedSentence]) -> Vec<f64> { |
|
let mut previous = None; |
|
let mut shifts = Vec::with_capacity(sentences.len()); |
|
|
|
for sentence in sentences { |
|
let current = sentence |
|
.tokens |
|
.iter() |
|
.filter(|token| is_content_pos(&token.pos)) |
|
.map(|token| token.surface.to_lowercase()) |
|
.collect::<BTreeSet<_>>(); |
|
let shift = previous.as_ref().map_or(0.0, |prior: &BTreeSet<String>| { |
|
let union_count = prior.union(¤t).count(); |
|
if union_count == 0 { |
|
0.0 |
|
} else { |
|
1.0 - prior.intersection(¤t).count() as f64 / union_count as f64 |
|
} |
|
}); |
|
shifts.push(shift); |
|
previous = Some(current); |
|
} |
|
|
|
shifts |
|
} |
|
|
|
fn frequency_pattern(values: &[f64]) -> FrequencyPattern { |
|
let sample_count = values.len(); |
|
let mean = if sample_count == 0 { |
|
0.0 |
|
} else { |
|
values.iter().sum::<f64>() / sample_count as f64 |
|
}; |
|
let maximum_autocorrelation = max_positive_lag_autocorrelation(values); |
|
let max_lag_autocorrelation_permutation_p_value = |
|
maximum_autocorrelation.and_then(|(_, correlation)| { |
|
max_lag_autocorrelation_permutation_p_value(values, correlation) |
|
}); |
|
if sample_count < 4 { |
|
return FrequencyPattern { |
|
sample_count, |
|
mean, |
|
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation), |
|
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag), |
|
max_lag_autocorrelation_permutation_p_value, |
|
..FrequencyPattern::default() |
|
}; |
|
} |
|
|
|
let centered = values.iter().map(|value| value - mean).collect::<Vec<_>>(); |
|
let mut total_power = 0.0; |
|
let mut dominant = None; |
|
for frequency_index in 1..=sample_count / 2 { |
|
let mut real = 0.0; |
|
let mut imaginary = 0.0; |
|
for (sample_index, value) in centered.iter().enumerate() { |
|
let phase = std::f64::consts::TAU * frequency_index as f64 * sample_index as f64 |
|
/ sample_count as f64; |
|
real += value * phase.cos(); |
|
imaginary -= value * phase.sin(); |
|
} |
|
let power = real.mul_add(real, imaginary * imaginary); |
|
total_power += power; |
|
if dominant.is_none_or(|(_, dominant_power)| power > dominant_power) { |
|
dominant = Some((frequency_index, power)); |
|
} |
|
} |
|
|
|
let Some((frequency_index, dominant_power)) = dominant else { |
|
return FrequencyPattern { |
|
sample_count, |
|
mean, |
|
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation), |
|
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag), |
|
max_lag_autocorrelation_permutation_p_value, |
|
..FrequencyPattern::default() |
|
}; |
|
}; |
|
if total_power <= f64::EPSILON { |
|
return FrequencyPattern { |
|
sample_count, |
|
mean, |
|
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation), |
|
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag), |
|
max_lag_autocorrelation_permutation_p_value, |
|
..FrequencyPattern::default() |
|
}; |
|
} |
|
|
|
FrequencyPattern { |
|
sample_count, |
|
mean, |
|
dominant_frequency_cycles_per_sentence: Some(frequency_index as f64 / sample_count as f64), |
|
dominant_period_sentences: Some(sample_count as f64 / frequency_index as f64), |
|
dominant_power_ratio: Some(dominant_power / total_power), |
|
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation), |
|
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag), |
|
max_lag_autocorrelation_permutation_p_value, |
|
} |
|
} |
|
|
|
fn max_positive_lag_autocorrelation(values: &[f64]) -> Option<(usize, f64)> { |
|
if values.len() < AUTOCORRELATION_MINIMUM_SAMPLES { |
|
return None; |
|
} |
|
let maximum_lag = (values.len() / 2).min(AUTOCORRELATION_MAXIMUM_LAG); |
|
let mut maximum = None; |
|
for lag in 1..=maximum_lag { |
|
let Some(correlation) = lag_pearson_correlation(values, lag) else { |
|
continue; |
|
}; |
|
if correlation <= 0.0 { |
|
continue; |
|
} |
|
if maximum.is_none_or(|(_, best)| correlation > best) { |
|
maximum = Some((lag, correlation)); |
|
} |
|
} |
|
maximum |
|
} |
|
|
|
fn max_lag_autocorrelation_permutation_p_value( |
|
values: &[f64], |
|
observed_correlation: f64, |
|
) -> Option<f64> { |
|
if values.len() < AUTOCORRELATION_MINIMUM_SAMPLES { |
|
return None; |
|
} |
|
let mut state = autocorrelation_seed(values); |
|
let mut at_least_as_large = 0_usize; |
|
let mut shuffled = values.to_vec(); |
|
for _ in 0..AUTOCORRELATION_PERMUTATIONS { |
|
shuffled.copy_from_slice(values); |
|
deterministic_shuffle(&mut shuffled, &mut state); |
|
if max_positive_lag_autocorrelation(&shuffled) |
|
.is_some_and(|(_, correlation)| correlation >= observed_correlation) |
|
{ |
|
at_least_as_large += 1; |
|
} |
|
} |
|
Some((at_least_as_large + 1) as f64 / (AUTOCORRELATION_PERMUTATIONS + 1) as f64) |
|
} |
|
|
|
fn lag_pearson_correlation(values: &[f64], lag: usize) -> Option<f64> { |
|
let paired_count = values.len().checked_sub(lag)?; |
|
if paired_count < 4 { |
|
return None; |
|
} |
|
let earlier = &values[..paired_count]; |
|
let later = &values[lag..]; |
|
let earlier_mean = earlier.iter().sum::<f64>() / paired_count as f64; |
|
let later_mean = later.iter().sum::<f64>() / paired_count as f64; |
|
let (covariance, earlier_sum_squares, later_sum_squares) = earlier.iter().zip(later).fold( |
|
(0.0, 0.0, 0.0), |
|
|(covariance, earlier_sum_squares, later_sum_squares), (earlier, later)| { |
|
let earlier_delta = earlier - earlier_mean; |
|
let later_delta = later - later_mean; |
|
( |
|
covariance + earlier_delta * later_delta, |
|
earlier_sum_squares + earlier_delta * earlier_delta, |
|
later_sum_squares + later_delta * later_delta, |
|
) |
|
}, |
|
); |
|
let denominator = (earlier_sum_squares * later_sum_squares).sqrt(); |
|
(denominator > f64::EPSILON).then_some(covariance / denominator) |
|
} |
|
|
|
fn autocorrelation_seed(values: &[f64]) -> u64 { |
|
values |
|
.iter() |
|
.enumerate() |
|
.fold(0x9e37_79b9_7f4a_7c15, |seed, (index, value)| { |
|
let rotated = value |
|
.to_bits() |
|
.rotate_left((index % u64::BITS as usize) as u32); |
|
seed.rotate_left(7) ^ rotated.wrapping_mul(0xbf58_476d_1ce4_e5b9) |
|
}) |
|
} |
|
|
|
fn deterministic_shuffle(values: &mut [f64], state: &mut u64) { |
|
for last_index in (1..values.len()).rev() { |
|
*state = state |
|
.wrapping_mul(6_364_136_223_846_793_005) |
|
.wrapping_add(1_442_695_040_888_963_407); |
|
let swap_index = (*state % (last_index + 1) as u64) as usize; |
|
values.swap(last_index, swap_index); |
|
} |
|
} |
|
|
|
fn pattern_profile_value(evaluation: &Evaluation, feature: &str) -> Option<f64> { |
|
match feature { |
|
"sentence_length_cv" => Some(evaluation.metrics.sentence_length_cv), |
|
"lexical_topic_shift_mean" => Some(evaluation.frequency.lexical_topic_shift.mean), |
|
"pos_bigram_entropy" => Some(evaluation.pos_sequence.pos_bigram_entropy), |
|
_ => None, |
|
} |
|
} |
|
|
|
fn profile_interval(values: &[f64]) -> Option<(f64, f64)> { |
|
if values.len() < ROBUST_PROFILE_MINIMUM_REFERENCES { |
|
return Some(( |
|
values.iter().copied().reduce(f64::min)?, |
|
values.iter().copied().reduce(f64::max)?, |
|
)); |
|
} |
|
Some(( |
|
quantile(values, PROFILE_LOWER_QUANTILE)?, |
|
quantile(values, PROFILE_UPPER_QUANTILE)?, |
|
)) |
|
} |
|
|
|
fn quantile(values: &[f64], quantile: f64) -> Option<f64> { |
|
let mut sorted = values.to_vec(); |
|
sorted.sort_by(f64::total_cmp); |
|
let last_index = sorted.len().checked_sub(1)?; |
|
let position = quantile * last_index as f64; |
|
let lower_index = position.floor() as usize; |
|
let upper_index = position.ceil() as usize; |
|
let interpolation = position - lower_index as f64; |
|
Some(sorted[lower_index] * (1.0 - interpolation) + sorted[upper_index] * interpolation) |
|
} |
|
|
|
fn push_bunsetsu_candidate( |
|
candidate_lengths: &mut Vec<usize>, |
|
current_length: &mut usize, |
|
candidate_count: &mut usize, |
|
) { |
|
if *current_length == 0 { |
|
return; |
|
} |
|
candidate_lengths.push(*current_length); |
|
*candidate_count += 1; |
|
*current_length = 0; |
|
} |
|
|
|
fn ends_bunsetsu_candidate(token: &MorphToken) -> bool { |
|
matches!(token.pos.as_str(), "助詞" | "助動詞" | "接続詞") |
|
} |
|
|
|
fn shannon_entropy(counts: &BTreeMap<String, usize>) -> f64 { |
|
let total = counts.values().sum::<usize>(); |
|
if total == 0 { |
|
return 0.0; |
|
} |
|
counts |
|
.values() |
|
.map(|count| { |
|
let probability = *count as f64 / total as f64; |
|
-probability * probability.log2() |
|
}) |
|
.sum() |
|
} |
|
|
|
fn mean_usize(values: &[usize]) -> f64 { |
|
if values.is_empty() { |
|
0.0 |
|
} else { |
|
values.iter().sum::<usize>() as f64 / values.len() as f64 |
|
} |
|
} |
|
|
|
fn coefficient_of_variation(values: &[usize], mean: f64) -> f64 { |
|
if values.is_empty() || mean == 0.0 { |
|
return 0.0; |
|
} |
|
let variance = values |
|
.iter() |
|
.map(|value| (*value as f64 - mean).powi(2)) |
|
.sum::<f64>() |
|
/ values.len() as f64; |
|
variance.sqrt() / mean |
|
} |
|
|
|
fn contrast_transitions(lengths: &[usize], mean: f64) -> usize { |
|
lengths |
|
.windows(2) |
|
.filter(|pair| (pair[0] as f64 - pair[1] as f64).abs() >= mean * 0.35) |
|
.count() |
|
} |
|
|
|
fn short_long_short_patterns(lengths: &[usize]) -> usize { |
|
lengths |
|
.windows(3) |
|
.filter(|window| { |
|
let first = window[0] as f64; |
|
let middle = window[1] as f64; |
|
let last = window[2] as f64; |
|
let outer_mean = (first + last) / 2.0; |
|
outer_mean > 0.0 && middle >= outer_mean * 1.6 && last <= middle * 0.55 |
|
}) |
|
.count() |
|
} |
|
|
|
fn percentage(numerator: usize, denominator: usize) -> f64 { |
|
if denominator == 0 { |
|
0.0 |
|
} else { |
|
(numerator as f64 / denominator as f64) * 100.0 |
|
} |
|
} |
|
|
|
fn capped_ratio(numerator: usize, denominator: usize) -> f64 { |
|
if denominator == 0 { |
|
0.0 |
|
} else { |
|
(numerator as f64 / denominator as f64).min(1.0) |
|
} |
|
} |
|
|
|
fn rhythm_score(metrics: &RhythmMetrics) -> f64 { |
|
let variation = (metrics.sentence_length_cv / 0.75).min(1.0); |
|
let cadence = capped_ratio( |
|
metrics.short_long_short_patterns, |
|
metrics.sentence_count.saturating_sub(2), |
|
); |
|
let transitions = capped_ratio( |
|
metrics.length_contrast_transitions, |
|
metrics.sentence_count.saturating_sub(1), |
|
); |
|
let hesitation = capped_ratio( |
|
metrics.hesitation_signal_sentences, |
|
((metrics.sentence_count as f64) * 0.33).ceil() as usize, |
|
); |
|
let short_stops = capped_ratio(metrics.short_terminal_sentences, metrics.sentence_count); |
|
|
|
100.0 |
|
* (0.30 * variation |
|
+ 0.30 * cadence |
|
+ 0.20 * transitions |
|
+ 0.10 * hesitation |
|
+ 0.10 * short_stops) |
|
} |
コーパス差分の追記\n\n良例仮説 5 文書・悪例仮説 4 文書を文書等重みで比較したところ、語彙的話題変化は文書 AUC=1.000、全126ラベル置換に対する p=0.00794、文長変動係数は AUC=1.000、p=0.01587 でした。POS bigram 反復率は p=0.13492 で弱い差です。\n\nこれは固定コーパスでの探索結果です。著者・媒体・題材・翻訳文体の交絡と、複数特徴の探索後検定を解消していないため、一般的な良文判定の精度とは主張しません。
research-notes-ja.mdに再現手順と表を記録しました。