Skip to content

Commit fa16ab0

Browse files
committed
feat: Add feature extraction and data analysis for classifier debugging
- Add extractHRFeatures(): derives pseudo-RMSSD, CV, range from HR samples - Add exportTrainingData(): exports raw data with per-stage feature analysis - Add Analyze button in Settings to inspect derived features per stage - This helps identify which features actually discriminate between stages
1 parent 1b07849 commit fa16ab0

2 files changed

Lines changed: 197 additions & 1 deletion

File tree

app/(tabs)/settings.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import {
3333
loadRemOptimizedModel,
3434
clearRemOptimizedModel,
3535
formatTrainingReport,
36-
type TrainingReport,
36+
exportTrainingData,
37+
formatExportedData,
3738
} from '@/services/remOptimizedClassifier';
3839

3940
export default function SettingsScreen() {
@@ -108,6 +109,19 @@ export default function SettingsScreen() {
108109
}
109110
};
110111

112+
const handleAnalyzeData = async () => {
113+
setIsTrainingRemOptimized(true);
114+
setRemOptimizedReport('Analyzing training data...');
115+
try {
116+
const data = await exportTrainingData(48);
117+
setRemOptimizedReport(formatExportedData(data));
118+
} catch (error) {
119+
setRemOptimizedReport(`Analysis failed: ${error}`);
120+
} finally {
121+
setIsTrainingRemOptimized(false);
122+
}
123+
};
124+
111125
const handleLoadRemOptimized = async () => {
112126
const model = await loadRemOptimizedModel();
113127
if (model) {
@@ -447,6 +461,16 @@ export default function SettingsScreen() {
447461
Train
448462
</Text>
449463
</Pressable>
464+
<Pressable
465+
style={styles.debugButton}
466+
onPress={handleAnalyzeData}
467+
disabled={isTrainingRemOptimized}
468+
>
469+
<Ionicons name="analytics-outline" size={16} color={colors.accent.cyan} />
470+
<Text variant="caption" color="primary">
471+
Analyze
472+
</Text>
473+
</Pressable>
450474
<Pressable
451475
style={styles.debugButton}
452476
onPress={handleClearRemOptimized}

services/remOptimizedClassifier.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,178 @@ export function to4Class(stage3: SleepStage3): SleepStage {
354354
}
355355
}
356356

357+
// ============================================================================
358+
// Feature Extraction from HR Samples
359+
// ============================================================================
360+
361+
interface HRFeatures {
362+
meanHR: number;
363+
stdHR: number;
364+
rangeHR: number;
365+
pseudoRMSSD: number;
366+
coefficientOfVariation: number;
367+
sampleCount: number;
368+
}
369+
370+
function extractHRFeatures(hrSamples: Array<{ beatsPerMinute: number; time: string }>): HRFeatures {
371+
if (hrSamples.length === 0) {
372+
return {
373+
meanHR: 0,
374+
stdHR: 0,
375+
rangeHR: 0,
376+
pseudoRMSSD: 0,
377+
coefficientOfVariation: 0,
378+
sampleCount: 0,
379+
};
380+
}
381+
382+
const hrs = hrSamples.map((s) => s.beatsPerMinute);
383+
const meanHR = mean(hrs);
384+
const stdHR = std(hrs);
385+
const rangeHR = Math.max(...hrs) - Math.min(...hrs);
386+
const coefficientOfVariation = meanHR > 0 ? stdHR / meanHR : 0;
387+
388+
let pseudoRMSSD = 0;
389+
if (hrSamples.length >= 2) {
390+
const sortedSamples = [...hrSamples].sort(
391+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
392+
);
393+
394+
const rrIntervals: number[] = [];
395+
for (const sample of sortedSamples) {
396+
if (sample.beatsPerMinute > 0) {
397+
rrIntervals.push(60000 / sample.beatsPerMinute);
398+
}
399+
}
400+
401+
if (rrIntervals.length >= 2) {
402+
const successiveDiffs: number[] = [];
403+
for (let i = 1; i < rrIntervals.length; i++) {
404+
successiveDiffs.push(Math.pow(rrIntervals[i] - rrIntervals[i - 1], 2));
405+
}
406+
if (successiveDiffs.length > 0) {
407+
pseudoRMSSD = Math.sqrt(mean(successiveDiffs));
408+
}
409+
}
410+
}
411+
412+
return {
413+
meanHR,
414+
stdHR,
415+
rangeHR,
416+
pseudoRMSSD,
417+
coefficientOfVariation,
418+
sampleCount: hrSamples.length,
419+
};
420+
}
421+
422+
// ============================================================================
423+
// Data Export for Local Analysis
424+
// ============================================================================
425+
426+
export interface ExportedTrainingData {
427+
exportTime: string;
428+
sleepStages: Array<{ stage: string; startTime: string; endTime: string }>;
429+
hrSamples: Array<{ beatsPerMinute: number; time: string }>;
430+
stageFeatures: Record<
431+
string,
432+
{
433+
hrFeatures: HRFeatures;
434+
durationMinutes: number;
435+
sampleCount: number;
436+
}
437+
>;
438+
}
439+
440+
export async function exportTrainingData(hoursBack: number = 48): Promise<ExportedTrainingData> {
441+
const platform = Platform.OS;
442+
443+
const [sleepStages, hrSamples] = await Promise.all([
444+
platform === 'ios'
445+
? healthKit.getRecentSleepSessions(hoursBack)
446+
: healthConnect.getRecentSleepSessions(hoursBack),
447+
platform === 'ios'
448+
? healthKit.getRecentHeartRate(hoursBack * 60)
449+
: healthConnect.getRecentHeartRate(hoursBack * 60),
450+
]);
451+
452+
const stageFeatures: ExportedTrainingData['stageFeatures'] = {};
453+
const STAGES: SleepStage3[] = ['awake', 'nrem', 'rem'];
454+
455+
for (const stage3 of STAGES) {
456+
const matchingHRs: Array<{ beatsPerMinute: number; time: string }> = [];
457+
let totalDurationMs = 0;
458+
459+
for (const stageRecord of sleepStages) {
460+
const stageStart = new Date(stageRecord.startTime);
461+
const stageEnd = new Date(stageRecord.endTime);
462+
const recordStage = to3Class(stageRecord.stage as SleepStage);
463+
464+
if (recordStage === stage3) {
465+
totalDurationMs += stageEnd.getTime() - stageStart.getTime();
466+
467+
const matching = hrSamples.filter((hr) => {
468+
const t = new Date(hr.time);
469+
return t >= stageStart && t <= stageEnd;
470+
});
471+
matchingHRs.push(...matching);
472+
}
473+
}
474+
475+
stageFeatures[stage3] = {
476+
hrFeatures: extractHRFeatures(matchingHRs),
477+
durationMinutes: totalDurationMs / 60000,
478+
sampleCount: matchingHRs.length,
479+
};
480+
}
481+
482+
return {
483+
exportTime: new Date().toISOString(),
484+
sleepStages: sleepStages.map((s) => ({
485+
stage: s.stage,
486+
startTime: s.startTime,
487+
endTime: s.endTime,
488+
})),
489+
hrSamples: hrSamples.map((h) => ({
490+
beatsPerMinute: h.beatsPerMinute,
491+
time: h.time,
492+
})),
493+
stageFeatures,
494+
};
495+
}
496+
497+
export function formatExportedData(data: ExportedTrainingData): string {
498+
const lines: string[] = [];
499+
lines.push('╔══════════════════════════════════════════════════════════════╗');
500+
lines.push('║ EXPORTED TRAINING DATA ANALYSIS ║');
501+
lines.push('╚══════════════════════════════════════════════════════════════╝');
502+
lines.push('');
503+
lines.push(`Export Time: ${data.exportTime}`);
504+
lines.push(`Sleep Stages: ${data.sleepStages.length}`);
505+
lines.push(`HR Samples: ${data.hrSamples.length}`);
506+
lines.push('');
507+
508+
lines.push('┌─────────────────────────────────────────────────────────────┐');
509+
lines.push('│ FEATURES BY STAGE │');
510+
lines.push('├─────────────────────────────────────────────────────────────┤');
511+
512+
for (const [stage, features] of Object.entries(data.stageFeatures)) {
513+
const f = features.hrFeatures;
514+
lines.push(`│ ${stage.toUpperCase()}`);
515+
lines.push(
516+
`│ Duration: ${features.durationMinutes.toFixed(1)} min (${features.sampleCount} HR samples)`
517+
);
518+
lines.push(`│ HR Mean±Std: ${f.meanHR.toFixed(1)}±${f.stdHR.toFixed(1)} bpm`);
519+
lines.push(`│ HR Range: ${f.rangeHR.toFixed(1)} bpm`);
520+
lines.push(`│ Pseudo-RMSSD: ${f.pseudoRMSSD.toFixed(1)} ms`);
521+
lines.push(`│ CV: ${(f.coefficientOfVariation * 100).toFixed(2)}%`);
522+
lines.push('│');
523+
}
524+
lines.push('└─────────────────────────────────────────────────────────────┘');
525+
526+
return lines.join('\n');
527+
}
528+
357529
// ============================================================================
358530
// Training from Historical Data
359531
// ============================================================================

0 commit comments

Comments
 (0)