feat: add example plugins (ai_image, calendar, music, rss, weather), fix .gitignore, move gengskill to tools/

This commit is contained in:
root
2026-07-21 12:45:17 +08:00
parent 75ae2b4692
commit 52dc22f86f
54 changed files with 13650 additions and 202 deletions

View File

@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
本福特定律检测 (Benford's Law Test)
====================================
原理:跨越多个数量级的自然数据,首位数字遵循特定概率分布:
P(d) = log10(1 + 1/d), d = 1,2,...,9
人为编造的数据往往偏离这一分布(倾向于均匀分布或集中在某些数字)。
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import math
import numpy as np
from collections import Counter
from scipy import stats
# 本福特定律理论概率
BENFORD_PROBS = {d: math.log10(1 + 1/d) for d in range(1, 10)}
def get_first_digit(value):
"""提取数值的首位有效数字1-9"""
try:
num = abs(float(value))
if num == 0:
return None
# 转为科学计数法取首位
s = f"{num:.10e}"
first = int(s[0])
if 1 <= first <= 9:
return first
except (ValueError, TypeError):
pass
return None
def get_first_two_digits(value):
"""提取前两位有效数字10-99"""
try:
num = abs(float(value))
if num == 0:
return None
while num < 10:
num *= 10
while num >= 100:
num /= 10
return int(num)
except (ValueError, TypeError):
pass
return None
def benford_test(values, order=1):
"""
执行本福特定律检测
Parameters
----------
values : list
待检测的数值列表
order : int
1 = 首位数字检测, 2 = 前两位数字检测
Returns
-------
dict : 检测结果
"""
if order == 1:
digits = []
for v in values:
d = get_first_digit(v)
if d is not None:
digits.append(d)
if len(digits) < 30:
return {
'status': 'insufficient_data',
'message': f'数据量不足(仅{len(digits)}个有效值本福特定律检测建议至少100个数据点',
'n_valid': len(digits)
}
# 统计观测频率
digit_counts = Counter(digits)
observed = np.array([digit_counts.get(d, 0) for d in range(1, 10)])
expected = np.array([BENFORD_PROBS[d] * len(digits) for d in range(1, 10)])
# 卡方检验
chi2, p_value = stats.chisquare(observed, expected)
# Kolmogorov-Smirnov 检验
observed_freq = observed / len(digits)
expected_freq = np.array([BENFORD_PROBS[d] for d in range(1, 10)])
# 最大绝对偏差 (MAD)
mad = np.mean(np.abs(observed_freq - expected_freq))
# MAD 阈值参考 (Nigrini 2012)
# Close conformity: MAD < 0.006
# Acceptable conformity: 0.006 <= MAD < 0.012
# Marginally acceptable: 0.012 <= MAD < 0.015
# Nonconformity: MAD >= 0.015
if mad < 0.006:
conformity = 'close'
conformity_cn = '高度符合'
elif mad < 0.012:
conformity = 'acceptable'
conformity_cn = '可接受'
elif mad < 0.015:
conformity = 'marginal'
conformity_cn = '边缘'
else:
conformity = 'nonconforming'
conformity_cn = '不符合'
# 风险评分
if p_value < 0.001 and mad >= 0.015:
risk_level = 'high'
risk_score = 75 + min(25, mad * 500)
elif p_value < 0.01:
risk_level = 'medium-high'
risk_score = 55 + min(20, mad * 400)
elif p_value < 0.05:
risk_level = 'medium'
risk_score = 35 + min(20, mad * 300)
else:
risk_level = 'low'
risk_score = max(0, mad * 200)
distribution = {
str(d): {
'observed': int(observed[d-1]),
'observed_freq': round(float(observed_freq[d-1]), 4),
'expected_freq': round(float(expected_freq[d-1]), 4),
'deviation': round(float(observed_freq[d-1] - expected_freq[d-1]), 4)
}
for d in range(1, 10)
}
result = {
'test_name': "Benford's Law Test (本福特定律检测)",
'status': 'completed',
'order': order,
'n_values': len(digits),
'distribution': distribution,
'chi_square': round(float(chi2), 4),
'p_value': float(p_value),
'degrees_of_freedom': 8,
'mean_absolute_deviation': round(float(mad), 6),
'conformity': conformity,
'conformity_cn': conformity_cn,
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'interpretation': _interpret_benford(p_value, mad, conformity_cn, len(digits)),
'note': '本福特定律适用于跨多个数量级的自然数据集。对于范围有限的数据如百分比、pH值该检测可能不适用。'
}
return result
else:
return {'status': 'error', 'message': '目前仅支持首位数字检测order=1'}
def _interpret_benford(p_value, mad, conformity_cn, n):
"""生成可读的解释"""
if p_value < 0.001 and mad >= 0.015:
return (
f"⚠️ 数据首位数字分布严重偏离本福特定律p < 0.001, MAD = {mad:.4f})。"
f"符合性判定:{conformity_cn}"
f"这种偏离在{n}个数据点的样本中非常显著,强烈建议核查数据来源。"
f"注意:需确认数据是否适用本福特定律(需跨越多个数量级)。"
)
elif p_value < 0.01:
return (
f"⚠️ 数据首位数字分布显著偏离本福特定律p < 0.01, MAD = {mad:.4f})。"
f"符合性判定:{conformity_cn}。建议进一步检查。"
)
elif p_value < 0.05:
return (
f"⚡ 数据首位数字分布存在一定偏离p < 0.05, MAD = {mad:.4f})。"
f"符合性判定:{conformity_cn}。可能是正常波动,建议结合其他检测综合判断。"
)
else:
return (
f"✅ 数据首位数字分布符合本福特定律p = {p_value:.4f}, MAD = {mad:.4f})。"
f"符合性判定:{conformity_cn}。未发现异常。"
)
def load_data(input_file, column=None, delimiter=','):
"""从CSV文件加载数据"""
import csv
values = []
with open(input_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=delimiter)
if column and column in reader.fieldnames:
for row in reader:
try:
val = row[column].strip()
if val:
float(val)
values.append(val)
except (ValueError, KeyError):
continue
else:
for row in reader:
for key, val in row.items():
try:
val = val.strip()
if val:
float(val)
values.append(val)
except (ValueError, AttributeError):
continue
return values
def main():
parser = argparse.ArgumentParser(
description="本福特定律检测 - 检测首位数字是否符合Benford's Law"
)
parser.add_argument('--input', '-i', required=True, help='输入CSV文件路径')
parser.add_argument('--column', '-c', help='要检测的列名')
parser.add_argument('--order', type=int, default=1, choices=[1, 2],
help='检测阶数1=首位, 2=前两位')
parser.add_argument('--delimiter', '-d', default=',', help='CSV分隔符')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
args = parser.parse_args()
values = load_data(args.input, args.column, args.delimiter)
if not values:
print("错误:未能加载有效数据", file=sys.stderr)
sys.exit(1)
result = benford_test(values, order=args.order)
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
小数位一致性检测 (Decimal Consistency Test)
============================================
原理:实验测量数据的小数位后数字应具有一定的随机性。
如果大量数据点的小数部分高度一致(如小数后两位总是相同),
或小数位数模式过于规律,则暗示数据可能是人为编造的。
这是"耿同学"常用的一个检测手段——造假者编造数据时,
小数点后的位数往往呈现不自然的一致性。
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import numpy as np
from collections import Counter
from scipy import stats
def get_decimal_digits(value, max_digits=4):
"""提取数值的小数部分各位数字"""
s = str(value).strip()
if '.' not in s:
return []
decimal_part = s.split('.')[1]
return [int(d) for d in decimal_part[:max_digits]]
def get_decimal_string(value):
"""提取数值的完整小数字符串"""
s = str(value).strip()
if '.' not in s:
return ''
return s.split('.')[1]
def count_decimal_places(value):
"""计算数值的小数位数"""
s = str(value).strip()
if '.' not in s:
return 0
return len(s.split('.')[1])
def decimal_consistency_test(values):
"""
执行小数位一致性检测
Parameters
----------
values : list
待检测的数值列表(字符串形式保留原始精度)
Returns
-------
dict : 检测结果
"""
if len(values) < 5:
return {
'status': 'insufficient_data',
'message': f'数据量不足(仅{len(values)}个值需要至少5个数据点'
}
# 分析1: 小数位数一致性
decimal_places = [count_decimal_places(v) for v in values]
places_counter = Counter(decimal_places)
most_common_places = places_counter.most_common(1)[0]
places_uniformity = most_common_places[1] / len(values)
# 分析2: 小数部分重复度
decimal_strings = [get_decimal_string(v) for v in values if '.' in str(v)]
if decimal_strings:
decimal_counter = Counter(decimal_strings)
n_unique_decimals = len(decimal_counter)
most_repeated = decimal_counter.most_common(1)[0]
max_repetition_rate = most_repeated[1] / len(decimal_strings)
else:
n_unique_decimals = 0
max_repetition_rate = 0
most_repeated = ('N/A', 0)
# 分析3: 各小数位数字分布
position_analyses = {}
max_positions = max(decimal_places) if decimal_places else 0
for pos in range(min(max_positions, 4)):
digits_at_pos = []
for v in values:
decs = get_decimal_digits(v)
if len(decs) > pos:
digits_at_pos.append(decs[pos])
if len(digits_at_pos) >= 10:
digit_counts = Counter(digits_at_pos)
observed = np.array([digit_counts.get(i, 0) for i in range(10)])
expected = np.full(10, len(digits_at_pos) / 10.0)
chi2, p_value = stats.chisquare(observed, expected)
position_analyses[f'position_{pos+1}'] = {
'n_values': len(digits_at_pos),
'distribution': {str(i): int(observed[i]) for i in range(10)},
'chi_square': round(float(chi2), 4),
'p_value': float(p_value),
'is_uniform': p_value > 0.05
}
# 分析4: 相邻数据小数部分相关性
if len(decimal_strings) >= 5:
# 将小数部分转为数值进行自相关分析
decimal_values = []
for ds in decimal_strings:
try:
decimal_values.append(float('0.' + ds) if ds else 0.0)
except ValueError:
decimal_values.append(0.0)
if len(decimal_values) >= 5:
# 计算一阶自相关
x = np.array(decimal_values)
x_centered = x - np.mean(x)
if np.std(x) > 0:
autocorr = np.correlate(x_centered[:-1], x_centered[1:]) / (len(x_centered) - 1) / np.var(x)
autocorr_val = float(autocorr[0]) if len(autocorr) > 0 else 0
else:
autocorr_val = 1.0 # 完全一致
else:
autocorr_val = None
else:
autocorr_val = None
# 综合风险评分
risk_factors = []
# 因子1: 小数位数过于一致
if places_uniformity > 0.95 and len(values) > 10:
risk_factors.append(('decimal_places_uniform', 20))
# 因子2: 小数部分重复度过高
if max_repetition_rate > 0.5:
risk_factors.append(('high_repetition', 30))
elif max_repetition_rate > 0.3:
risk_factors.append(('moderate_repetition', 15))
# 因子3: 某个位置数字分布异常
for pos_key, pos_data in position_analyses.items():
if pos_data['p_value'] < 0.001:
risk_factors.append((f'{pos_key}_nonuniform', 25))
elif pos_data['p_value'] < 0.01:
risk_factors.append((f'{pos_key}_marginal', 10))
# 因子4: 自相关异常高
if autocorr_val is not None and abs(autocorr_val) > 0.8:
risk_factors.append(('high_autocorrelation', 20))
risk_score = min(100, sum(score for _, score in risk_factors))
if risk_score >= 70:
risk_level = 'high'
elif risk_score >= 45:
risk_level = 'medium-high'
elif risk_score >= 25:
risk_level = 'medium'
else:
risk_level = 'low'
result = {
'test_name': 'Decimal Consistency Test (小数位一致性检测)',
'status': 'completed',
'n_values': len(values),
'decimal_places_analysis': {
'distribution': dict(places_counter),
'most_common_places': most_common_places[0],
'uniformity_rate': round(float(places_uniformity), 4)
},
'decimal_repetition': {
'n_unique_patterns': n_unique_decimals,
'most_repeated_pattern': most_repeated[0],
'most_repeated_count': most_repeated[1],
'max_repetition_rate': round(float(max_repetition_rate), 4)
},
'position_digit_analysis': position_analyses,
'autocorrelation': round(float(autocorr_val), 4) if autocorr_val is not None else None,
'risk_factors': [f for f, _ in risk_factors],
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'interpretation': _interpret_decimal(risk_factors, places_uniformity, max_repetition_rate, most_repeated)
}
return result
def _interpret_decimal(risk_factors, places_uniformity, max_repetition_rate, most_repeated):
"""生成可读的解释"""
if not risk_factors:
return "✅ 小数位分布未发现明显异常,数据的小数部分具有合理的随机性。"
issues = []
for factor, _ in risk_factors:
if 'repetition' in factor:
issues.append(f"小数部分 '{most_repeated[0]}' 重复出现 {most_repeated[1]} 次({max_repetition_rate:.0%}")
elif 'nonuniform' in factor:
issues.append("某些小数位的数字分布严重偏离均匀分布")
elif 'autocorrelation' in factor:
issues.append("相邻数据的小数部分存在异常高的自相关")
elif 'places_uniform' in factor:
issues.append(f"所有数据小数位数高度一致({places_uniformity:.0%}相同)")
issues_str = "".join(issues)
if len(risk_factors) >= 3:
return f"⚠️ 发现多项小数位异常:{issues_str}。这些模式在自然实验数据中非常罕见,强烈建议核查原始数据。"
elif len(risk_factors) >= 2:
return f"⚠️ 发现小数位可疑模式:{issues_str}。建议进一步检查。"
else:
return f"⚡ 发现轻微异常:{issues_str}。可能是测量精度限制导致,建议结合其他检测综合判断。"
def load_data(input_file, column=None, delimiter=','):
"""从CSV文件加载数据保留原始字符串精度"""
import csv
values = []
with open(input_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=delimiter)
if column and column in reader.fieldnames:
for row in reader:
val = row[column].strip()
if val:
try:
float(val)
values.append(val)
except ValueError:
continue
else:
for row in reader:
for key, val in row.items():
try:
val = val.strip()
if val:
float(val)
values.append(val)
except (ValueError, AttributeError):
continue
return values
def main():
parser = argparse.ArgumentParser(
description='小数位一致性检测 - 检测数据小数部分是否存在异常模式'
)
parser.add_argument('--input', '-i', required=True, help='输入CSV文件路径')
parser.add_argument('--column', '-c', help='要检测的列名')
parser.add_argument('--delimiter', '-d', default=',', help='CSV分隔符')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
args = parser.parse_args()
values = load_data(args.input, args.column, args.delimiter)
if not values:
print("错误:未能加载有效数据", file=sys.stderr)
sys.exit(1)
result = decimal_consistency_test(values)
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
固定关系检测 (Fixed Relationship Detection)
============================================
原理:两组独立实验数据之间不应存在恒定的差值、比值或线性关系。
如果不同实验条件下的数据存在固定的数学关系,暗示数据可能是
从单一数据源通过简单数学运算生成的,而非独立实验获得。
这是"耿同学"打假方法中的核心策略之一——他发现许多造假论文中
不同实验组的数据存在固定差值或固定比例关系。
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import numpy as np
from scipy import stats
def detect_fixed_difference(col1, col2, tolerance=0.01):
"""检测两列数据是否存在固定差值"""
differences = np.array(col2) - np.array(col1)
if len(differences) < 3:
return None
# 计算差值的变异系数
mean_diff = np.mean(differences)
std_diff = np.std(differences)
if abs(mean_diff) < 1e-10:
cv = float('inf') if std_diff > 0 else 0
else:
cv = abs(std_diff / mean_diff)
# 判断差值是否恒定CV极小
is_fixed = cv < tolerance and std_diff < tolerance * abs(mean_diff + 1e-10)
# 检查差值是否完全相同
unique_diffs = np.unique(np.round(differences, 6))
is_exact = len(unique_diffs) == 1
return {
'type': 'fixed_difference',
'type_cn': '固定差值',
'mean_difference': round(float(mean_diff), 6),
'std_difference': round(float(std_diff), 6),
'cv': round(float(cv), 6) if cv != float('inf') else 'inf',
'is_fixed': bool(is_fixed or is_exact),
'is_exact': bool(is_exact),
'n_unique_differences': int(len(unique_diffs))
}
def detect_fixed_ratio(col1, col2, tolerance=0.01):
"""检测两列数据是否存在固定比值"""
col1 = np.array(col1, dtype=float)
col2 = np.array(col2, dtype=float)
# 避免除以零
mask = col1 != 0
if np.sum(mask) < 3:
return None
ratios = col2[mask] / col1[mask]
mean_ratio = np.mean(ratios)
std_ratio = np.std(ratios)
if abs(mean_ratio) < 1e-10:
cv = float('inf') if std_ratio > 0 else 0
else:
cv = abs(std_ratio / mean_ratio)
is_fixed = cv < tolerance
unique_ratios = np.unique(np.round(ratios, 6))
is_exact = len(unique_ratios) == 1
return {
'type': 'fixed_ratio',
'type_cn': '固定比值',
'mean_ratio': round(float(mean_ratio), 6),
'std_ratio': round(float(std_ratio), 6),
'cv': round(float(cv), 6) if cv != float('inf') else 'inf',
'is_fixed': bool(is_fixed or is_exact),
'is_exact': bool(is_exact),
'n_unique_ratios': int(len(unique_ratios))
}
def detect_linear_relationship(col1, col2):
"""检测两列数据是否存在高度线性关系"""
col1 = np.array(col1, dtype=float)
col2 = np.array(col2, dtype=float)
if len(col1) < 3:
return None
# 线性回归
slope, intercept, r_value, p_value, std_err = stats.linregress(col1, col2)
r_squared = r_value ** 2
# 残差分析
predicted = slope * col1 + intercept
residuals = col2 - predicted
max_residual = np.max(np.abs(residuals))
mean_residual = np.mean(np.abs(residuals))
# R² 非常接近1且残差极小
is_suspicious = r_squared > 0.9999 and max_residual < 0.001 * np.std(col2)
return {
'type': 'linear_relationship',
'type_cn': '线性关系',
'slope': round(float(slope), 6),
'intercept': round(float(intercept), 6),
'r_squared': round(float(r_squared), 8),
'p_value': float(p_value),
'max_residual': round(float(max_residual), 8),
'mean_residual': round(float(mean_residual), 8),
'is_suspicious': bool(is_suspicious)
}
def detect_decimal_pattern(col1, col2):
"""检测两列数据小数部分是否高度一致"""
col1 = np.array(col1, dtype=float)
col2 = np.array(col2, dtype=float)
# 提取小数部分
dec1 = col1 - np.floor(col1)
dec2 = col2 - np.floor(col2)
# 检查小数部分是否一致
dec_diff = np.abs(dec1 - dec2)
n_matching = np.sum(dec_diff < 0.001)
match_rate = n_matching / len(col1)
return {
'type': 'decimal_pattern',
'type_cn': '小数位一致性',
'n_matching_decimals': int(n_matching),
'match_rate': round(float(match_rate), 4),
'is_suspicious': match_rate > 0.8
}
def fixed_relation_test(col1, col2, col1_name='Column A', col2_name='Column B'):
"""
综合固定关系检测
Parameters
----------
col1 : list of float
第一列数据
col2 : list of float
第二列数据
Returns
-------
dict : 检测结果
"""
if len(col1) != len(col2):
return {'status': 'error', 'message': '两列数据长度不一致'}
if len(col1) < 3:
return {'status': 'insufficient_data', 'message': '数据量不足至少需要3个数据点'}
col1 = [float(x) for x in col1]
col2 = [float(x) for x in col2]
# 执行各项检测
results = {}
diff_result = detect_fixed_difference(col1, col2)
if diff_result:
results['fixed_difference'] = diff_result
ratio_result = detect_fixed_ratio(col1, col2)
if ratio_result:
results['fixed_ratio'] = ratio_result
linear_result = detect_linear_relationship(col1, col2)
if linear_result:
results['linear_relationship'] = linear_result
decimal_result = detect_decimal_pattern(col1, col2)
if decimal_result:
results['decimal_pattern'] = decimal_result
# 综合风险评估
n_suspicious = sum([
1 for r in results.values()
if r.get('is_fixed') or r.get('is_suspicious')
])
if n_suspicious >= 3:
risk_level = 'high'
risk_score = 85
elif n_suspicious == 2:
risk_level = 'medium-high'
risk_score = 65
elif n_suspicious == 1:
risk_level = 'medium'
risk_score = 45
else:
risk_level = 'low'
risk_score = 10
# 如果存在完全精确的固定关系,直接拉高风险
if any(r.get('is_exact') for r in results.values()):
risk_level = 'high'
risk_score = max(risk_score, 90)
summary = {
'test_name': 'Fixed Relationship Detection (固定关系检测)',
'status': 'completed',
'n_data_points': len(col1),
'column_1': col1_name,
'column_2': col2_name,
'detections': results,
'n_suspicious_patterns': n_suspicious,
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'interpretation': _interpret_fixed_relation(results, n_suspicious, col1_name, col2_name)
}
return summary
def _interpret_fixed_relation(results, n_suspicious, col1_name, col2_name):
"""生成可读的解释"""
findings = []
if results.get('fixed_difference', {}).get('is_fixed'):
d = results['fixed_difference']
findings.append(f"两列数据存在固定差值 {d['mean_difference']}")
if results.get('fixed_ratio', {}).get('is_fixed'):
r = results['fixed_ratio']
findings.append(f"两列数据存在固定比值 {r['mean_ratio']}")
if results.get('linear_relationship', {}).get('is_suspicious'):
l = results['linear_relationship']
findings.append(f"两列数据存在完美线性关系 (R² = {l['r_squared']})")
if results.get('decimal_pattern', {}).get('is_suspicious'):
p = results['decimal_pattern']
findings.append(f"两列数据小数部分高度一致 (匹配率 {p['match_rate']:.0%})")
if not findings:
return f"{col1_name}{col2_name} 之间未发现固定数学关系,数据看起来是独立的。"
findings_str = "".join(findings)
return (
f"⚠️ {col1_name}{col2_name} 之间发现以下可疑模式:{findings_str}"
f"独立实验数据通常不应存在如此精确的数学关系,建议核查数据是否来自独立实验。"
)
def load_data(input_file, col1, col2, delimiter=','):
"""从CSV文件加载两列数据"""
import csv
data1, data2 = [], []
with open(input_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=delimiter)
for row in reader:
try:
v1 = float(row[col1].strip())
v2 = float(row[col2].strip())
data1.append(v1)
data2.append(v2)
except (ValueError, KeyError):
continue
return data1, data2
def main():
parser = argparse.ArgumentParser(
description='固定关系检测 - 检测两组数据间是否存在不自然的数学关系'
)
parser.add_argument('--input', '-i', required=True, help='输入CSV文件路径')
parser.add_argument('--col1', required=True, help='第一列列名')
parser.add_argument('--col2', required=True, help='第二列列名')
parser.add_argument('--delimiter', '-d', default=',', help='CSV分隔符')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
args = parser.parse_args()
data1, data2 = load_data(args.input, args.col1, args.col2, args.delimiter)
if not data1:
print("错误:未能加载有效数据", file=sys.stderr)
sys.exit(1)
result = fixed_relation_test(data1, data2, args.col1, args.col2)
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,463 @@
#!/usr/bin/env python3
"""
Geng 综合评估引擎 (Comprehensive Assessment Engine)
=====================================================
一键运行所有适用的检测模块,生成综合学术数据打假报告。
支持的领域:
- biomedical: 生物医学Western blot, 流式, 动物实验)
- chemistry: 化学(光谱, 产率, 催化)
- physics: 物理/材料(性能曲线, 电学/力学)
- social_science: 社会科学(问卷, 量表)
- clinical: 临床医学(生存数据, 临床指标)
- general: 通用(不指定领域)
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import os
import csv
import time
from datetime import datetime
from pathlib import Path
# 导入各检测模块
from last_digit_test import last_digit_test
from benford_test import benford_test
from decimal_consistency_test import decimal_consistency_test
from fixed_relation_test import fixed_relation_test
from grim_test import grim_test_batch
def load_csv_data(input_file, delimiter=','):
"""加载CSV数据返回列名和数据"""
columns = {}
with open(input_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=delimiter)
fieldnames = reader.fieldnames
for row in reader:
for col in fieldnames:
if col not in columns:
columns[col] = []
columns[col].append(row[col].strip() if row[col] else '')
return columns, fieldnames
def identify_numeric_columns(columns):
"""识别数值列"""
numeric_cols = {}
for col_name, values in columns.items():
numeric_values = []
for v in values:
try:
if v:
float(v)
numeric_values.append(v)
except ValueError:
continue
# 至少50%的值是数值
if len(numeric_values) >= len(values) * 0.5 and len(numeric_values) >= 5:
numeric_cols[col_name] = numeric_values
return numeric_cols
def run_assessment(input_file, domain='general', output_dir=None, delimiter=','):
"""
执行综合评估
Parameters
----------
input_file : str
输入CSV文件
domain : str
研究领域
output_dir : str
输出目录
delimiter : str
CSV分隔符
"""
start_time = time.time()
# 加载数据
columns, fieldnames = load_csv_data(input_file, delimiter)
numeric_cols = identify_numeric_columns(columns)
if not numeric_cols:
return {
'status': 'error',
'message': '未找到有效的数值列,请检查输入文件格式'
}
report = {
'meta': {
'tool': 'Geng Academic Data Fraud Detection Tool',
'version': '1.0.0',
'timestamp': datetime.now().isoformat(),
'input_file': os.path.basename(input_file),
'domain': domain,
'n_columns': len(fieldnames),
'n_numeric_columns': len(numeric_cols),
'numeric_columns': list(numeric_cols.keys()),
'n_rows': max(len(v) for v in columns.values()) if columns else 0
},
'module_results': {},
'summary': {}
}
all_risk_scores = []
# ==========================================
# Module 1: 末位数字检测 (对每个数值列)
# ==========================================
print("🔍 执行末位数字检测...")
last_digit_results = {}
for col_name, values in numeric_cols.items():
result = last_digit_test(values, method='all_digits')
if result.get('status') == 'completed':
last_digit_results[col_name] = result
all_risk_scores.append(result.get('risk_score', 0))
if last_digit_results:
report['module_results']['last_digit_test'] = {
'module_name': '末位数字检测',
'description': '检测数据末位数字是否偏离均匀分布',
'columns_tested': len(last_digit_results),
'results': last_digit_results
}
# ==========================================
# Module 2: 本福特定律检测 (对每个数值列)
# ==========================================
print("🔍 执行本福特定律检测...")
benford_results = {}
for col_name, values in numeric_cols.items():
# 本福特定律适用于跨多个数量级的数据
try:
float_values = [float(v) for v in values if v]
value_range = max(float_values) / (min(v for v in float_values if v > 0) + 1e-10)
# 只对跨度超过1个数量级的列做本福特检测
if value_range > 10:
result = benford_test(values, order=1)
if result.get('status') == 'completed':
benford_results[col_name] = result
all_risk_scores.append(result.get('risk_score', 0))
except (ValueError, ZeroDivisionError):
continue
if benford_results:
report['module_results']['benford_test'] = {
'module_name': '本福特定律检测',
'description': '检测首位数字是否符合Benford\'s Law',
'columns_tested': len(benford_results),
'results': benford_results
}
# ==========================================
# Module 3: 小数位一致性检测 (对每个数值列)
# ==========================================
print("🔍 执行小数位一致性检测...")
decimal_results = {}
for col_name, values in numeric_cols.items():
# 只检测包含小数的列
has_decimal = any('.' in v for v in values if v)
if has_decimal:
result = decimal_consistency_test(values)
if result.get('status') == 'completed':
decimal_results[col_name] = result
all_risk_scores.append(result.get('risk_score', 0))
if decimal_results:
report['module_results']['decimal_consistency_test'] = {
'module_name': '小数位一致性检测',
'description': '检测数据小数部分是否存在异常模式',
'columns_tested': len(decimal_results),
'results': decimal_results
}
# ==========================================
# Module 4: 固定关系检测 (两两比较数值列)
# ==========================================
print("🔍 执行固定关系检测...")
fixed_results = {}
col_names = list(numeric_cols.keys())
# 限制比较对数,避免组合爆炸
max_pairs = min(10, len(col_names) * (len(col_names) - 1) // 2)
pair_count = 0
for i in range(len(col_names)):
if pair_count >= max_pairs:
break
for j in range(i + 1, len(col_names)):
if pair_count >= max_pairs:
break
col1_name = col_names[i]
col2_name = col_names[j]
# 确保两列长度一致且有足够数据
vals1 = numeric_cols[col1_name]
vals2 = numeric_cols[col2_name]
# 配对:只取两列都有值的行
paired_1, paired_2 = [], []
for v1, v2 in zip(vals1, vals2):
try:
if v1 and v2:
float(v1)
float(v2)
paired_1.append(v1)
paired_2.append(v2)
except ValueError:
continue
if len(paired_1) >= 5:
result = fixed_relation_test(paired_1, paired_2, col1_name, col2_name)
if result.get('status') == 'completed':
pair_key = f"{col1_name} vs {col2_name}"
fixed_results[pair_key] = result
all_risk_scores.append(result.get('risk_score', 0))
pair_count += 1
if fixed_results:
report['module_results']['fixed_relation_test'] = {
'module_name': '固定关系检测',
'description': '检测不同列数据间是否存在不自然的数学关系',
'pairs_tested': len(fixed_results),
'results': fixed_results
}
# ==========================================
# 综合评分
# ==========================================
print("📊 生成综合评估...")
if all_risk_scores:
# 综合评分:取各模块最高分的加权平均
max_score = max(all_risk_scores)
mean_score = sum(all_risk_scores) / len(all_risk_scores)
# 综合分 = 60% 最高分 + 40% 平均分
overall_score = 0.6 * max_score + 0.4 * mean_score
overall_score = min(100, overall_score)
else:
overall_score = 0
# 统计各风险等级
high_risk_modules = [s for s in all_risk_scores if s >= 70]
medium_risk_modules = [s for s in all_risk_scores if 40 <= s < 70]
low_risk_modules = [s for s in all_risk_scores if s < 40]
if overall_score >= 75:
overall_level = 'critical'
overall_level_cn = '🔴 极高风险'
overall_emoji = '🔴'
elif overall_score >= 50:
overall_level = 'high'
overall_level_cn = '🟠 高风险'
overall_emoji = '🟠'
elif overall_score >= 25:
overall_level = 'medium'
overall_level_cn = '🟡 中风险'
overall_emoji = '🟡'
else:
overall_level = 'low'
overall_level_cn = '🟢 低风险'
overall_emoji = '🟢'
report['summary'] = {
'overall_risk_score': round(float(overall_score), 1),
'overall_risk_level': overall_level,
'overall_risk_level_cn': overall_level_cn,
'n_modules_run': len(report['module_results']),
'n_tests_total': len(all_risk_scores),
'n_high_risk': len(high_risk_modules),
'n_medium_risk': len(medium_risk_modules),
'n_low_risk': len(low_risk_modules),
'execution_time_seconds': round(time.time() - start_time, 2),
'conclusion': _generate_conclusion(overall_score, overall_level, report['module_results']),
'recommendations': _generate_recommendations(overall_level, domain, report['module_results'])
}
# 保存报告
if output_dir:
os.makedirs(output_dir, exist_ok=True)
report_path = os.path.join(output_dir, 'geng_assessment_report.json')
with open(report_path, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
# 生成可读的 Markdown 报告
md_path = os.path.join(output_dir, 'geng_assessment_report.md')
with open(md_path, 'w', encoding='utf-8') as f:
f.write(_generate_markdown_report(report))
print(f"\n📄 JSON报告已保存至: {report_path}")
print(f"📄 Markdown报告已保存至: {md_path}")
return report
def _generate_conclusion(score, level, modules):
"""生成结论"""
if level == 'critical':
return (
"⚠️ 综合评估显示数据存在系统性异常,多项检测指标显著偏离正常预期。"
"强烈建议对原始实验数据进行全面核查。"
"注意:本工具仅提供线索筛查,最终判定需要领域专家复核。"
)
elif level == 'high':
return (
"⚠️ 数据中发现多处可疑模式,部分指标显著异常。"
"建议对标记为高风险的数据列进行重点核查。"
)
elif level == 'medium':
return (
"⚡ 数据存在一些轻微异常,但不足以构成造假的确证。"
"可能是测量精度限制、数据处理方式等正常因素导致。"
"建议结合论文方法学描述进行综合判断。"
)
else:
return (
"✅ 数据各项检测指标均在正常范围内,未发现明显的造假迹象。"
"注意:通过检测不代表数据一定真实,某些高明的造假可能无法被统计方法捕获。"
)
def _generate_recommendations(level, domain, modules):
"""生成建议"""
recs = []
if level in ('critical', 'high'):
recs.append("核查原始实验记录和数据记录本")
recs.append("验证数据是否来自独立实验")
recs.append("联系通讯作者要求提供原始数据")
if domain == 'biomedical':
recs.append("检查Western blot原始图片和流式原始FCS文件")
recs.append("考虑向期刊或机构提交正式质疑")
elif level == 'medium':
recs.append("仔细阅读论文方法学部分,确认数据采集方式")
recs.append("检查是否存在合理的解释(如仪器精度限制)")
recs.append("可考虑联系作者进行非正式沟通")
else:
recs.append("当前数据未发现明显异常")
recs.append("可考虑对补充材料中的数据做进一步检测")
return recs
def _generate_markdown_report(report):
"""生成Markdown格式报告"""
meta = report['meta']
summary = report['summary']
md = []
md.append("# 📋 Geng 学术数据打假检测报告\n")
md.append(f"> 生成时间: {meta['timestamp']}")
md.append(f"> 检测工具: {meta['tool']} v{meta['version']}")
md.append('> 致敬"耿同学讲故事"\n')
md.append("## 📊 综合评估结果\n")
md.append(f"| 指标 | 结果 |")
md.append(f"|------|------|")
md.append(f"| **综合风险评分** | **{summary['overall_risk_score']}/100** |")
md.append(f"| **风险等级** | {summary['overall_risk_level_cn']} |")
md.append(f"| 输入文件 | {meta['input_file']} |")
md.append(f"| 检测领域 | {meta['domain']} |")
md.append(f"| 数值列数 | {meta['n_numeric_columns']} |")
md.append(f"| 数据行数 | {meta['n_rows']} |")
md.append(f"| 运行模块数 | {summary['n_modules_run']} |")
md.append(f"| 检测总数 | {summary['n_tests_total']} |")
md.append(f"| 高风险项 | {summary['n_high_risk']} |")
md.append(f"| 执行耗时 | {summary['execution_time_seconds']}s |\n")
md.append("## 📝 结论\n")
md.append(f"{summary['conclusion']}\n")
md.append("## 💡 建议\n")
for i, rec in enumerate(summary['recommendations'], 1):
md.append(f"{i}. {rec}")
md.append("")
md.append("## 🔬 各模块检测详情\n")
for module_key, module_data in report['module_results'].items():
md.append(f"### {module_data['module_name']}\n")
md.append(f"_{module_data['description']}_\n")
if 'columns_tested' in module_data:
md.append(f"- 检测列数: {module_data['columns_tested']}")
if 'pairs_tested' in module_data:
md.append(f"- 检测对数: {module_data['pairs_tested']}")
# 列出各列/对的风险评分
results = module_data.get('results', {})
if results:
md.append(f"\n| 检测对象 | 风险评分 | 风险等级 | 说明 |")
md.append(f"|----------|----------|----------|------|")
for key, res in results.items():
score = res.get('risk_score', 'N/A')
level = res.get('risk_level', 'N/A')
interp = res.get('interpretation', '')[:60]
md.append(f"| {key} | {score} | {level} | {interp}... |")
md.append("")
md.append("---\n")
md.append("## ⚠️ 重要声明\n")
md.append("- 本工具仅用于辅助筛查,**不能作为造假的最终判定依据**")
md.append("- 数据异常 ≠ 数据造假(可能是仪器校准、单位转换、排版错误等)")
md.append("- 检测结果需要领域专家复核")
md.append("- 使用本工具时应遵守学术伦理和法律法规\n")
md.append("---\n")
md.append('*Powered by Geng Skill — 致敬"耿同学讲故事"*')
return "\n".join(md)
def main():
parser = argparse.ArgumentParser(
description='Geng 综合评估引擎 - 一键运行所有检测模块'
)
parser.add_argument('--input', '-i', required=True, help='输入CSV文件路径')
parser.add_argument('--domain', default='general',
choices=['biomedical', 'chemistry', 'physics',
'social_science', 'clinical', 'general'],
help='研究领域')
parser.add_argument('--output', '-o', default='./report', help='输出目录')
parser.add_argument('--delimiter', '-d', default=',', help='CSV分隔符')
args = parser.parse_args()
if not os.path.isfile(args.input):
print(f"错误:文件不存在: {args.input}", file=sys.stderr)
sys.exit(1)
print(f"{'='*60}")
print(f" Geng 学术数据打假检测工具 v1.0.0")
print(' 致敬"耿同学讲故事" — 用数据说话,让造假无所遁形')
print(f"{'='*60}")
print(f"\n📁 输入文件: {args.input}")
print(f"🔬 检测领域: {args.domain}")
print(f"📂 输出目录: {args.output}\n")
report = run_assessment(args.input, args.domain, args.output, args.delimiter)
if report.get('status') == 'error':
print(f"\n❌ 错误: {report['message']}", file=sys.stderr)
sys.exit(1)
summary = report['summary']
print(f"\n{'='*60}")
print(f" 综合评估结果")
print(f"{'='*60}")
print(f"\n {summary['overall_risk_level_cn']}")
print(f" 综合风险评分: {summary['overall_risk_score']}/100")
print(f" 高风险项: {summary['n_high_risk']} | "
f"中风险项: {summary['n_medium_risk']} | "
f"低风险项: {summary['n_low_risk']}")
print(f"\n {summary['conclusion']}")
print(f"\n{'='*60}")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
GRIM 测试 (Granularity-Related Inconsistency of Means)
========================================================
原理对于整数取值的数据如李克特量表1-5、年龄等
给定样本量 n合法的平均值只能取有限集合中的值。
如果报告的平均值不在合法集合中,则数据存在不一致性。
n=25数据取值为整数则平均值只能是 k/25 的形式,
小数部分只能是 .00, .04, .08, .12, ..., .96
参考Brown & Heathers (2017). The GRIM Test. SPPS.
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import math
from decimal import Decimal, ROUND_HALF_UP
def grim_test_single(mean, n, decimals=2, scale_min=None, scale_max=None):
"""
对单个均值执行 GRIM 测试
Parameters
----------
mean : float or str
报告的平均值
n : int
样本量
decimals : int
报告的小数位数
scale_min : int, optional
量表最小值(用于范围检查)
scale_max : int, optional
量表最大值(用于范围检查)
Returns
-------
dict : 检测结果
"""
try:
mean_val = Decimal(str(mean))
n = int(n)
except (ValueError, TypeError) as e:
return {'status': 'error', 'message': f'无效输入: {e}'}
if n <= 0:
return {'status': 'error', 'message': '样本量必须大于0'}
# 范围检查
if scale_min is not None and scale_max is not None:
if float(mean_val) < scale_min or float(mean_val) > scale_max:
return {
'status': 'range_error',
'message': f'均值 {mean} 超出量表范围 [{scale_min}, {scale_max}]',
'consistent': False
}
# 计算总和 = mean * n
total = mean_val * n
# 对于整数取值数据,总和必须是整数
# 考虑四舍五入误差:检查 total 是否足够接近某个整数
granularity = Decimal(1) / Decimal(10 ** decimals)
# 在四舍五入精度范围内检查
# mean 可能是真实值四舍五入到 decimals 位的结果
# 真实 mean 在 [mean - 0.5*granularity, mean + 0.5*granularity) 范围内
lower_total = (mean_val - granularity / 2) * n
upper_total = (mean_val + granularity / 2) * n
# 检查这个范围内是否包含整数
lower_int = math.ceil(float(lower_total))
upper_int = math.floor(float(upper_total))
consistent = lower_int <= upper_int
# 计算最近的合法均值
nearest_total = round(float(mean_val * n))
nearest_mean = nearest_total / n
# 格式化到指定小数位
fmt = f"%.{decimals}f"
nearest_mean_str = fmt % nearest_mean
reported_mean_str = fmt % float(mean_val)
result = {
'reported_mean': str(mean),
'sample_size': n,
'decimals': decimals,
'consistent': consistent,
'computed_sum': float(mean_val * n),
'nearest_valid_mean': nearest_mean_str,
'difference': round(abs(float(mean_val) - nearest_mean), decimals + 2)
}
if scale_min is not None and scale_max is not None:
result['scale_range'] = f"[{scale_min}, {scale_max}]"
return result
def grim_test_batch(items):
"""
批量 GRIM 测试
Parameters
----------
items : list of dict
每个字典包含 'mean', 'n', 可选 'decimals', 'label'
Returns
-------
dict : 批量检测结果
"""
results = []
n_inconsistent = 0
for i, item in enumerate(items):
mean = item.get('mean')
n = item.get('n')
decimals = item.get('decimals', 2)
label = item.get('label', f'Item {i+1}')
scale_min = item.get('scale_min')
scale_max = item.get('scale_max')
res = grim_test_single(mean, n, decimals, scale_min, scale_max)
res['label'] = label
results.append(res)
if not res.get('consistent', True):
n_inconsistent += 1
# 整体评估
total = len(results)
inconsistency_rate = n_inconsistent / total if total > 0 else 0
# 风险评分
if inconsistency_rate > 0.5:
risk_level = 'high'
risk_score = 70 + inconsistency_rate * 30
elif inconsistency_rate > 0.25:
risk_level = 'medium-high'
risk_score = 50 + inconsistency_rate * 40
elif inconsistency_rate > 0:
risk_level = 'medium'
risk_score = 30 + inconsistency_rate * 40
else:
risk_level = 'low'
risk_score = 0
summary = {
'test_name': 'GRIM Test (均值粒度一致性检验)',
'status': 'completed',
'total_items': total,
'inconsistent_items': n_inconsistent,
'inconsistency_rate': round(inconsistency_rate, 4),
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'details': results,
'interpretation': _interpret_grim(n_inconsistent, total, inconsistency_rate)
}
return summary
def _interpret_grim(n_inconsistent, total, rate):
"""生成可读的解释"""
if n_inconsistent == 0:
return f"✅ 全部 {total} 个均值通过 GRIM 检验,未发现数值不一致。"
elif rate > 0.5:
return (
f"⚠️ {total} 个均值中有 {n_inconsistent} 个({rate:.0%})未通过 GRIM 检验。"
f"超过半数均值与样本量不兼容,这是严重的数据不一致信号。"
f"强烈建议核查原始数据。"
)
elif rate > 0.25:
return (
f"⚠️ {total} 个均值中有 {n_inconsistent} 个({rate:.0%})未通过 GRIM 检验。"
f"建议仔细核查这些不一致的数据点。"
)
else:
return (
f"{total} 个均值中有 {n_inconsistent} 个({rate:.0%})未通过 GRIM 检验。"
f"少量不一致可能是四舍五入方式不同导致,建议结合其他检测综合判断。"
)
def main():
parser = argparse.ArgumentParser(
description='GRIM测试 - 检测报告均值与样本量的一致性'
)
parser.add_argument('--mean', type=str, help='报告的平均值')
parser.add_argument('--n', type=int, help='样本量')
parser.add_argument('--decimals', type=int, default=2, help='小数位数')
parser.add_argument('--scale', type=str, help='量表范围,如 "1-5"')
parser.add_argument('--input', '-i', help='输入JSON文件批量测试')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
args = parser.parse_args()
scale_min, scale_max = None, None
if args.scale:
parts = args.scale.split('-')
if len(parts) == 2:
scale_min, scale_max = int(parts[0]), int(parts[1])
if args.input:
# 批量模式
with open(args.input, 'r', encoding='utf-8') as f:
items = json.load(f)
result = grim_test_batch(items)
elif args.mean and args.n:
# 单项模式
res = grim_test_single(args.mean, args.n, args.decimals, scale_min, scale_max)
result = {
'test_name': 'GRIM Test (均值粒度一致性检验)',
'status': 'completed',
'result': res,
'interpretation': (
f"✅ 均值 {args.mean} 与样本量 {args.n} 一致" if res.get('consistent')
else f"⚠️ 均值 {args.mean} 与样本量 {args.n} 不一致!最近合法均值为 {res.get('nearest_valid_mean')}"
)
}
else:
parser.print_help()
sys.exit(1)
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
图像重复检测 (Image Duplication Detection)
============================================
原理:检测论文图片中是否存在重复使用或篡改的图像。
使用感知哈希(pHash)和结构相似性(SSIM)来识别:
1. 完全相同的图片出现在不同实验条件下
2. 经过旋转、翻转、裁剪后重复使用的图片
3. 调整亮度/对比度后复用的图片
这是学术造假中常见的手段尤其在Western blot、
显微镜图片、流式细胞术散点图等场景中。
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import os
from pathlib import Path
try:
from PIL import Image
import numpy as np
HAS_PILLOW = True
except ImportError:
HAS_PILLOW = False
try:
from skimage.metrics import structural_similarity as ssim
HAS_SKIMAGE = True
except ImportError:
HAS_SKIMAGE = False
def average_hash(image, hash_size=16):
"""计算平均感知哈希"""
img = image.convert('L').resize((hash_size, hash_size), Image.LANCZOS)
pixels = np.array(img)
mean = pixels.mean()
return (pixels > mean).flatten()
def difference_hash(image, hash_size=16):
"""计算差异感知哈希"""
img = image.convert('L').resize((hash_size + 1, hash_size), Image.LANCZOS)
pixels = np.array(img)
return (pixels[:, 1:] > pixels[:, :-1]).flatten()
def hamming_distance(hash1, hash2):
"""计算汉明距离归一化到0-1"""
return np.sum(hash1 != hash2) / len(hash1)
def compute_ssim(img1, img2, target_size=(256, 256)):
"""计算结构相似性指数"""
if not HAS_SKIMAGE:
return None
# 统一尺寸
img1_resized = img1.convert('L').resize(target_size, Image.LANCZOS)
img2_resized = img2.convert('L').resize(target_size, Image.LANCZOS)
arr1 = np.array(img1_resized)
arr2 = np.array(img2_resized)
score = ssim(arr1, arr2)
return float(score)
def check_rotations(img1, img2, threshold=0.85):
"""检查图像经过旋转/翻转后是否匹配"""
transformations = [
('original', lambda x: x),
('rotate_90', lambda x: x.rotate(90, expand=True)),
('rotate_180', lambda x: x.rotate(180, expand=True)),
('rotate_270', lambda x: x.rotate(270, expand=True)),
('flip_horizontal', lambda x: x.transpose(Image.FLIP_LEFT_RIGHT)),
('flip_vertical', lambda x: x.transpose(Image.FLIP_TOP_BOTTOM)),
]
best_match = None
best_score = 0
hash1 = average_hash(img1)
for name, transform in transformations:
transformed = transform(img2)
hash2 = average_hash(transformed)
similarity = 1 - hamming_distance(hash1, hash2)
if similarity > best_score:
best_score = similarity
best_match = name
return {
'best_transformation': best_match,
'best_similarity': round(best_score, 4),
'is_match': best_score >= threshold
}
def find_duplicates(image_dir, threshold=0.85, extensions=None):
"""
在目录中查找重复或相似的图片
Parameters
----------
image_dir : str
图片目录路径
threshold : float
相似度阈值0-1超过此值判定为重复
extensions : list
支持的图片格式
Returns
-------
dict : 检测结果
"""
if not HAS_PILLOW:
return {
'status': 'error',
'message': '需要安装 Pillow: pip install Pillow'
}
if extensions is None:
extensions = ['.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp', '.gif']
# 收集所有图片文件
image_files = []
for ext in extensions:
image_files.extend(Path(image_dir).glob(f'*{ext}'))
image_files.extend(Path(image_dir).glob(f'*{ext.upper()}'))
image_files = sorted(set(image_files))
if len(image_files) < 2:
return {
'status': 'insufficient_data',
'message': f'目录中仅找到 {len(image_files)} 张图片需要至少2张进行比较',
'n_images': len(image_files)
}
# 计算所有图片的哈希
hashes = {}
for img_path in image_files:
try:
img = Image.open(img_path)
hashes[str(img_path)] = {
'avg_hash': average_hash(img),
'diff_hash': difference_hash(img),
'size': img.size,
'image': img
}
except Exception as e:
continue
# 两两比较
duplicates = []
paths = list(hashes.keys())
for i in range(len(paths)):
for j in range(i + 1, len(paths)):
path1, path2 = paths[i], paths[j]
h1, h2 = hashes[path1], hashes[path2]
# 平均哈希相似度
avg_sim = 1 - hamming_distance(h1['avg_hash'], h2['avg_hash'])
# 差异哈希相似度
diff_sim = 1 - hamming_distance(h1['diff_hash'], h2['diff_hash'])
# 综合相似度
combined_sim = max(avg_sim, diff_sim)
if combined_sim >= threshold:
pair_result = {
'file_1': os.path.basename(path1),
'file_2': os.path.basename(path2),
'avg_hash_similarity': round(float(avg_sim), 4),
'diff_hash_similarity': round(float(diff_sim), 4),
'combined_similarity': round(float(combined_sim), 4),
}
# 检查旋转/翻转匹配
rotation_check = check_rotations(
h1['image'], h2['image'], threshold
)
pair_result['rotation_check'] = rotation_check
# SSIM如果可用
if HAS_SKIMAGE:
ssim_score = compute_ssim(h1['image'], h2['image'])
pair_result['ssim'] = round(ssim_score, 4)
duplicates.append(pair_result)
# 关闭所有图片
for h in hashes.values():
h['image'].close()
# 风险评分
n_duplicates = len(duplicates)
n_images = len(image_files)
if n_duplicates == 0:
risk_level = 'low'
risk_score = 0
elif n_duplicates <= 1:
risk_level = 'medium'
risk_score = 40
elif n_duplicates <= 3:
risk_level = 'medium-high'
risk_score = 60
else:
risk_level = 'high'
risk_score = 80 + min(20, n_duplicates * 3)
# 如果有完美匹配(相似度>0.98),直接拉高
perfect_matches = [d for d in duplicates if d['combined_similarity'] > 0.98]
if perfect_matches:
risk_score = max(risk_score, 90)
risk_level = 'high'
result = {
'test_name': 'Image Duplication Detection (图像重复检测)',
'status': 'completed',
'n_images_scanned': n_images,
'n_duplicate_pairs': n_duplicates,
'threshold': threshold,
'duplicates': duplicates,
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'interpretation': _interpret_image(n_duplicates, n_images, duplicates)
}
return result
def _interpret_image(n_duplicates, n_images, duplicates):
"""生成可读的解释"""
if n_duplicates == 0:
return f"✅ 在 {n_images} 张图片中未发现重复或高度相似的图像对。"
perfect = [d for d in duplicates if d['combined_similarity'] > 0.98]
if perfect:
return (
f"⚠️ 发现 {len(perfect)} 对近乎完全相同的图片!"
f"这些图片可能是同一图片的重复使用,强烈建议核查是否为不同实验条件下的独立数据。"
)
else:
return (
f"⚡ 发现 {n_duplicates} 对高度相似的图片(共扫描 {n_images} 张)。"
f"可能存在图片复用或篡改,建议人工核查具体图片内容。"
)
def main():
parser = argparse.ArgumentParser(
description='图像重复检测 - 检测论文图片是否存在重复使用或篡改'
)
parser.add_argument('--input_dir', '-i', required=True, help='图片目录路径')
parser.add_argument('--threshold', '-t', type=float, default=0.85,
help='相似度阈值0-1默认0.85')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
args = parser.parse_args()
if not os.path.isdir(args.input_dir):
print(f"错误:目录不存在: {args.input_dir}", file=sys.stderr)
sys.exit(1)
result = find_duplicates(args.input_dir, args.threshold)
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""
图像重复检测 (Image Duplication Detection)
============================================
原理:检测论文图片中是否存在重复使用或篡改的图像。
使用感知哈希(pHash)和结构相似性(SSIM)来识别:
1. 完全相同的图片出现在不同实验条件下
2. 经过旋转、翻转、裁剪后重复使用的图片
3. 调整亮度/对比度后复用的图片
这是学术造假中常见的手段尤其在Western blot、
显微镜图片、流式细胞术散点图等场景中。
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import os
from pathlib import Path
try:
from PIL import Image
import numpy as np
class NumpyEncoder(json.JSONEncoder):
def default(self, obj):
import numpy as np
if isinstance(obj, (np.integer,)): return int(obj)
if isinstance(obj, (np.floating,)): return float(obj)
if isinstance(obj, (np.bool_,)): return bool(obj)
if isinstance(obj, np.ndarray): return obj.tolist()
if obj is None: return None
return super().default(obj)
HAS_PILLOW = True
except ImportError:
HAS_PILLOW = False
try:
from skimage.metrics import structural_similarity as ssim
HAS_SKIMAGE = True
except ImportError:
HAS_SKIMAGE = False
def average_hash(image, hash_size=16):
"""计算平均感知哈希"""
img = image.convert('L').resize((hash_size, hash_size), Image.LANCZOS)
pixels = np.array(img)
mean = pixels.mean()
return (pixels > mean).flatten()
def difference_hash(image, hash_size=16):
"""计算差异感知哈希"""
img = image.convert('L').resize((hash_size + 1, hash_size), Image.LANCZOS)
pixels = np.array(img)
return (pixels[:, 1:] > pixels[:, :-1]).flatten()
def hamming_distance(hash1, hash2):
"""计算汉明距离归一化到0-1"""
return np.sum(hash1 != hash2) / len(hash1)
def compute_ssim(img1, img2, target_size=(256, 256)):
"""计算结构相似性指数"""
if not HAS_SKIMAGE:
return None
# 统一尺寸
img1_resized = img1.convert('L').resize(target_size, Image.LANCZOS)
img2_resized = img2.convert('L').resize(target_size, Image.LANCZOS)
arr1 = np.array(img1_resized)
arr2 = np.array(img2_resized)
score = ssim(arr1, arr2)
return float(score)
def check_rotations(img1, img2, threshold=0.85):
"""检查图像经过旋转/翻转后是否匹配"""
transformations = [
('original', lambda x: x),
('rotate_90', lambda x: x.rotate(90, expand=True)),
('rotate_180', lambda x: x.rotate(180, expand=True)),
('rotate_270', lambda x: x.rotate(270, expand=True)),
('flip_horizontal', lambda x: x.transpose(Image.FLIP_LEFT_RIGHT)),
('flip_vertical', lambda x: x.transpose(Image.FLIP_TOP_BOTTOM)),
]
best_match = None
best_score = 0
hash1 = average_hash(img1)
for name, transform in transformations:
transformed = transform(img2)
hash2 = average_hash(transformed)
similarity = 1 - hamming_distance(hash1, hash2)
if similarity > best_score:
best_score = similarity
best_match = name
return {
'best_transformation': best_match,
'best_similarity': round(best_score, 4),
'is_match': best_score >= threshold
}
def find_duplicates(image_dir, threshold=0.85, extensions=None):
"""
在目录中查找重复或相似的图片
Parameters
----------
image_dir : str
图片目录路径
threshold : float
相似度阈值0-1超过此值判定为重复
extensions : list
支持的图片格式
Returns
-------
dict : 检测结果
"""
if not HAS_PILLOW:
return {
'status': 'error',
'message': '需要安装 Pillow: pip install Pillow'
}
if extensions is None:
extensions = ['.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp', '.gif']
# 收集所有图片文件
image_files = []
for ext in extensions:
image_files.extend(Path(image_dir).glob(f'*{ext}'))
image_files.extend(Path(image_dir).glob(f'*{ext.upper()}'))
image_files = sorted(set(image_files))
if len(image_files) < 2:
return {
'status': 'insufficient_data',
'message': f'目录中仅找到 {len(image_files)} 张图片需要至少2张进行比较',
'n_images': len(image_files)
}
# 计算所有图片的哈希
hashes = {}
for img_path in image_files:
try:
img = Image.open(img_path)
hashes[str(img_path)] = {
'avg_hash': average_hash(img),
'diff_hash': difference_hash(img),
'size': img.size,
'image': img
}
except Exception as e:
continue
# 两两比较
duplicates = []
paths = list(hashes.keys())
for i in range(len(paths)):
for j in range(i + 1, len(paths)):
path1, path2 = paths[i], paths[j]
h1, h2 = hashes[path1], hashes[path2]
# 平均哈希相似度
avg_sim = 1 - hamming_distance(h1['avg_hash'], h2['avg_hash'])
# 差异哈希相似度
diff_sim = 1 - hamming_distance(h1['diff_hash'], h2['diff_hash'])
# 综合相似度
combined_sim = max(avg_sim, diff_sim)
if combined_sim >= threshold:
pair_result = {
'file_1': os.path.basename(path1),
'file_2': os.path.basename(path2),
'avg_hash_similarity': round(float(avg_sim), 4),
'diff_hash_similarity': round(float(diff_sim), 4),
'combined_similarity': round(float(combined_sim), 4),
}
# 检查旋转/翻转匹配
rotation_check = check_rotations(
h1['image'], h2['image'], threshold
)
pair_result['rotation_check'] = rotation_check
# SSIM如果可用
if HAS_SKIMAGE:
ssim_score = compute_ssim(h1['image'], h2['image'])
pair_result['ssim'] = round(ssim_score, 4)
duplicates.append(pair_result)
# 关闭所有图片
for h in hashes.values():
h['image'].close()
# 风险评分
n_duplicates = len(duplicates)
n_images = len(image_files)
if n_duplicates == 0:
risk_level = 'low'
risk_score = 0
elif n_duplicates <= 1:
risk_level = 'medium'
risk_score = 40
elif n_duplicates <= 3:
risk_level = 'medium-high'
risk_score = 60
else:
risk_level = 'high'
risk_score = 80 + min(20, n_duplicates * 3)
# 如果有完美匹配(相似度>0.98),直接拉高
perfect_matches = [d for d in duplicates if d['combined_similarity'] > 0.98]
if perfect_matches:
risk_score = max(risk_score, 90)
risk_level = 'high'
result = {
'test_name': 'Image Duplication Detection (图像重复检测)',
'status': 'completed',
'n_images_scanned': n_images,
'n_duplicate_pairs': n_duplicates,
'threshold': threshold,
'duplicates': duplicates,
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'interpretation': _interpret_image(n_duplicates, n_images, duplicates)
}
return result
def _interpret_image(n_duplicates, n_images, duplicates):
"""生成可读的解释"""
if n_duplicates == 0:
return f"✅ 在 {n_images} 张图片中未发现重复或高度相似的图像对。"
perfect = [d for d in duplicates if d['combined_similarity'] > 0.98]
if perfect:
return (
f"⚠️ 发现 {len(perfect)} 对近乎完全相同的图片!"
f"这些图片可能是同一图片的重复使用,强烈建议核查是否为不同实验条件下的独立数据。"
)
else:
return (
f"⚡ 发现 {n_duplicates} 对高度相似的图片(共扫描 {n_images} 张)。"
f"可能存在图片复用或篡改,建议人工核查具体图片内容。"
)
def main():
parser = argparse.ArgumentParser(
description='图像重复检测 - 检测论文图片是否存在重复使用或篡改'
)
parser.add_argument('--input_dir', '-i', required=True, help='图片目录路径')
parser.add_argument('--threshold', '-t', type=float, default=0.85,
help='相似度阈值0-1默认0.85')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
args = parser.parse_args()
if not os.path.isdir(args.input_dir):
print(f"错误:目录不存在: {args.input_dir}", file=sys.stderr)
sys.exit(1)
result = find_duplicates(args.input_dir, args.threshold)
output_json = json.dumps(result, ensure_ascii=False, indent=2, cls=NumpyEncoder)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
末位数字检测 (Last Digit Test)
==============================
原理自然实验数据的末位数字0-9应近似均匀分布。
如果数据是人为编造的,末位数字往往会集中在某些特定值上。
使用卡方检验评估偏离均匀分布的程度。
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
"""
import argparse
import sys
import json
import numpy as np
from collections import Counter
from scipy import stats
def extract_last_digit(value):
"""提取数值的末位有效数字"""
s = str(value).strip()
# 去除负号
s = s.lstrip('-')
# 去除科学计数法
if 'e' in s.lower():
try:
value = float(s)
s = f"{value:.10f}".rstrip('0')
except ValueError:
return None
# 找到末位有效数字
s = s.rstrip('0').rstrip('.')
if not s:
return 0
for c in reversed(s):
if c.isdigit():
return int(c)
return None
def extract_last_digit_with_decimals(value, use_decimal_last=True):
"""
提取数值的末位数字
use_decimal_last=True: 取小数点后最后一位非零数字
use_decimal_last=False: 取整数部分的末位数字
"""
s = str(value).strip()
s = s.lstrip('-')
if '.' in s and use_decimal_last:
decimal_part = s.split('.')[1]
# 取小数部分的最后一位数字
if decimal_part:
return int(decimal_part[-1])
# 取整数部分的末位
integer_part = s.split('.')[0] if '.' in s else s
if integer_part:
return int(integer_part[-1])
return None
def last_digit_test(values, method='all_digits'):
"""
执行末位数字检测
Parameters
----------
values : list of float/str
待检测的数值列表
method : str
'all_digits' - 检测最后一位有效数字
'decimal_last' - 检测小数末位
Returns
-------
dict : 检测结果
"""
# 提取末位数字
last_digits = []
for v in values:
try:
if method == 'decimal_last':
d = extract_last_digit_with_decimals(v, use_decimal_last=True)
else:
d = extract_last_digit(v)
if d is not None:
last_digits.append(d)
except (ValueError, TypeError):
continue
if len(last_digits) < 10:
return {
'status': 'insufficient_data',
'message': f'数据量不足(仅{len(last_digits)}个有效值需要至少10个数据点',
'n_valid': len(last_digits)
}
# 统计各数字出现频次
digit_counts = Counter(last_digits)
observed = np.array([digit_counts.get(i, 0) for i in range(10)])
expected = np.full(10, len(last_digits) / 10.0)
# 卡方检验
chi2, p_value = stats.chisquare(observed, expected)
# 计算集中度指标
max_digit = int(np.argmax(observed))
max_freq = observed[max_digit] / len(last_digits)
# 均匀性评分 (0=完全均匀, 1=完全集中)
uniformity_deviation = np.sqrt(np.sum((observed / len(last_digits) - 0.1) ** 2) / 10) / 0.3
uniformity_deviation = min(uniformity_deviation, 1.0)
# 风险评分
if p_value < 0.001:
risk_level = 'high'
risk_score = min(80 + (1 - p_value) * 20, 100)
elif p_value < 0.01:
risk_level = 'medium-high'
risk_score = 60 + (0.01 - p_value) / 0.009 * 20
elif p_value < 0.05:
risk_level = 'medium'
risk_score = 40 + (0.05 - p_value) / 0.04 * 20
else:
risk_level = 'low'
risk_score = max(0, 40 * (1 - p_value))
result = {
'test_name': 'Last Digit Test (末位数字检测)',
'status': 'completed',
'n_values': len(last_digits),
'method': method,
'digit_distribution': {str(i): int(observed[i]) for i in range(10)},
'chi_square': round(float(chi2), 4),
'p_value': float(p_value),
'degrees_of_freedom': 9,
'most_frequent_digit': max_digit,
'most_frequent_proportion': round(float(max_freq), 4),
'uniformity_deviation': round(float(uniformity_deviation), 4),
'risk_level': risk_level,
'risk_score': round(float(risk_score), 1),
'interpretation': _interpret_result(p_value, max_digit, max_freq, len(last_digits))
}
return result
def _interpret_result(p_value, max_digit, max_freq, n):
"""生成可读的解释"""
if p_value < 0.001:
return (
f"⚠️ 末位数字分布严重偏离均匀分布p < 0.001)。"
f"数字 {max_digit} 出现频率为 {max_freq:.1%}(期望 10%"
f"这种偏离在自然实验数据中极为罕见,强烈建议进一步核查原始数据。"
)
elif p_value < 0.01:
return (
f"⚠️ 末位数字分布显著偏离均匀分布p < 0.01)。"
f"数字 {max_digit} 出现频率为 {max_freq:.1%},建议关注并进行人工复核。"
)
elif p_value < 0.05:
return (
f"⚡ 末位数字分布存在一定偏离p < 0.05)。"
f"可能是正常波动,也可能提示数据存在问题,建议结合其他检测结果综合判断。"
)
else:
return (
f"✅ 末位数字分布与均匀分布无显著差异p = {p_value:.4f}"
f"未发现明显异常。"
)
def load_data(input_file, column=None, delimiter=','):
"""从CSV文件加载数据"""
import csv
values = []
with open(input_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=delimiter)
if column and column in reader.fieldnames:
for row in reader:
try:
val = row[column].strip()
if val:
float(val) # 验证是数值
values.append(val)
except (ValueError, KeyError):
continue
else:
# 如果没有指定列,尝试读取第一个数值列
for row in reader:
for key, val in row.items():
try:
val = val.strip()
if val:
float(val)
values.append(val)
except (ValueError, AttributeError):
continue
return values
def main():
parser = argparse.ArgumentParser(
description='末位数字检测 - 检测数据末位数字是否偏离均匀分布'
)
parser.add_argument('--input', '-i', required=True, help='输入CSV文件路径')
parser.add_argument('--column', '-c', help='要检测的列名')
parser.add_argument('--method', '-m', default='all_digits',
choices=['all_digits', 'decimal_last'],
help='检测方法all_digits=末位有效数字, decimal_last=小数末位')
parser.add_argument('--delimiter', '-d', default=',', help='CSV分隔符')
parser.add_argument('--output', '-o', help='输出JSON文件路径')
parser.add_argument('--values', nargs='+', type=str,
help='直接传入数值列表(不使用文件输入)')
args = parser.parse_args()
if args.values:
values = args.values
else:
values = load_data(args.input, args.column, args.delimiter)
if not values:
print("错误:未能加载有效数据", file=sys.stderr)
sys.exit(1)
result = last_digit_test(values, method=args.method)
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
print(f"结果已保存至: {args.output}")
else:
print(output_json)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Wrapper to run image_duplicate_test with proper numpy handling"""
import sys
import json
import numpy as np
# We'll monkey-patch json to handle numpy types
class NumpyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, (np.bool_,)):
return bool(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
if obj is None:
return None
return super().default(obj)
# Save original dumps
original_dumps = json.dumps
def patched_dumps(obj, **kwargs):
kwargs.setdefault('cls', NumpyEncoder)
return original_dumps(obj, **kwargs)
json.dumps = patched_dumps
# Now import and run the original script
sys.argv = ['image_duplicate_test.py',
'--input_dir', '/home/program/qq-workspace/self-workplace/geng-skills/paper_images/figures',
'--threshold', '0.85',
'--output', '/home/program/qq-workspace/self-workplace/geng-skills/paper_images/duplicate_results.json']
exec(open('scripts/image_duplicate_test.py').read())

File diff suppressed because it is too large Load Diff