File size: 9,896 Bytes
575e16b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import os, io, re, json, zipfile, argparse, random
from pathlib import Path
from urllib.parse import unquote
import numpy as np, pandas as pd, torch, h5py
from scipy.io import wavfile
from sklearn.metrics import accuracy_score, classification_report, f1_score
from src.audio import logmel, resample, crop_offsets, SR, DURATION
from src.model import InsectCNN

ROOT=Path(__file__).resolve().parent

def load_rows(data_dir=ROOT/'data'):
    """Return dicts with the 16 kHz waveform, label, split and source session of every CSV row found in the zips."""
    rows=[]
    for csv,zipname in [('Cicadidae.csv','Cicadidae.zip'),('Orthoptera.csv','Orthoptera.zip')]:
        df=pd.read_csv(Path(data_dir)/csv)
        with zipfile.ZipFile(Path(data_dir)/zipname) as z:
            wavs=[n for n in z.namelist() if n.lower().endswith('.wav') and '/._' not in n]
            lookup={}
            for n in wavs: lookup.setdefault(n.split('/')[-1],n)
            for _,r in df.iterrows():
                fn=str(r['file_name'])
                member=lookup.get(fn)
                if not member:
                    matches=[n for n in wavs if n.endswith('/'+fn)]
                    if matches: member=matches[0]
                if not member:
                    print('MISSING',fn); continue
                sr,y=wavfile.read(io.BytesIO(z.read(member)))
                if y.ndim==2: y=y.astype(np.float32).mean(axis=1)
                if np.issubdtype(y.dtype,np.integer):
                    y=y.astype(np.float32)/max(abs(np.iinfo(y.dtype).min),np.iinfo(y.dtype).max)
                else: y=y.astype(np.float32)
                rows.append({'wave':resample(y,sr),'label':int(r['class_ID']),'species':str(r['species']),
                             'split':str(r['data_set']).lower(),'file_name':fn,
                             'session':session_key(str(r['species']),str(r['original_file_name']))})
    return rows

def session_key(species, original_file_name):
    """Group files cut from the same source recording/tape (e.g. 'MHV 313 ... #6a/#6b', 'dat008-019')."""
    s=unquote(original_file_name)
    for p in [r'(MHV\s*\d+)',r'(dat\d+)',r'([A-Z]+_\d{8})',r'(\d{6})_']:
        m=re.match(p,s)
        if m: return species+'|'+m.group(1)
    return species+'|'+s.rsplit('.',1)[0]

def features(waves, offsets):
    return torch.from_numpy(np.stack([logmel(w,SR,o) for w,o in zip(waves,offsets)]))

def predict_probs(model, rows, idx, eval_crops=1):
    """Per-file class probabilities, averaged over eval_crops evenly spaced 2 s windows."""
    model.eval(); out=[]
    with torch.no_grad():
        for i in idx:
            offs=crop_offsets(len(rows[i]['wave']),eval_crops)
            out.append(torch.softmax(model(features([rows[i]['wave']]*len(offs),offs)),dim=1).mean(dim=0).numpy())
    return np.stack(out) if out else np.empty((0,32))

def evaluate_split(model, rows, idx, eval_crops=1, names=None, n_boot=1000, seed=42):
    y=np.array([rows[i]['label'] for i in idx]); pred=np.argmax(predict_probs(model,rows,idx,eval_crops),axis=1)
    res={'count':int(len(idx)),'accuracy':float(accuracy_score(y,pred)),
         'macro_f1':float(f1_score(y,pred,average='macro',zero_division=0)),
         'weighted_f1':float(f1_score(y,pred,average='weighted',zero_division=0))}
    # Bootstrap over files: with ~74 test files the point estimates are very uncertain.
    rng=np.random.default_rng(seed); acc=[]; mf1=[]
    for _ in range(n_boot):
        b=rng.integers(0,len(y),len(y))
        acc.append(accuracy_score(y[b],pred[b])); mf1.append(f1_score(y[b],pred[b],average='macro',zero_division=0))
    res['accuracy_ci95']=[float(np.percentile(acc,2.5)),float(np.percentile(acc,97.5))]
    res['macro_f1_ci95']=[float(np.percentile(mf1,2.5)),float(np.percentile(mf1,97.5))]
    if names:
        res['report']=classification_report(y,pred,labels=list(range(32)),target_names=[names[str(i)] for i in range(32)],output_dict=True,zero_division=0)
    return res

def evaluate_test(model, rows, eval_crops=1, names=None):
    """Official test split, plus the subset whose source session never occurs in train/validation."""
    split=np.array([r['split'] for r in rows]); test=np.where(split=='test')[0]
    seen={r['session'] for r in rows if r['split']!='test'}
    clean=np.array([i for i in test if rows[i]['session'] not in seen])
    return {'test':evaluate_split(model,rows,test,eval_crops,names),
            'test_session_clean':evaluate_split(model,rows,clean,eval_crops) if len(clean) else None}

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument('--epochs',type=int,default=16); ap.add_argument('--seed',type=int,default=42)
    ap.add_argument('--output',default='.'); ap.add_argument('--data-dir',default=str(ROOT/'data'))
    ap.add_argument('--init-from',default=None,help='model dir to warm-start from (fine-tuning)')
    ap.add_argument('--lr',type=float,default=1e-3)
    ap.add_argument('--random-crop',action='store_true',help='train on a fresh random 2 s window per file each epoch')
    ap.add_argument('--eval-crops',type=int,default=1,help='average over K evenly spaced windows for validation/test')
    args=ap.parse_args()
    random.seed(args.seed); np.random.seed(args.seed); torch.manual_seed(args.seed)
    torch.set_num_threads(max(1,min(4,os.cpu_count() or 1)))
    rows=load_rows(args.data_dir)
    if not rows: raise RuntimeError('No audio rows loaded. Check data/ zip and CSV files.')
    y=np.array([r['label'] for r in rows]); split=np.array([r['split'] for r in rows])
    train=np.where(split=='train')[0]; val=np.where(split=='validation')[0]; test=np.where(split=='test')[0]
    # Dataset-provided partitions are preserved; no random file-level split. Test is only scored once, after selection.
    waves=[r['wave'] for r in rows]; yt=torch.tensor(y,dtype=torch.long)
    if args.init_from:
        from src.inference import load_model
        model,_,_=load_model(args.init_from)
    else:
        model=InsectCNN(32)
    n=int(SR*DURATION)
    xcenter=features(waves,[None]*len(waves)) if not args.random_crop else None
    counts=np.bincount(y[train],minlength=32)
    weights=np.array([len(train)/(32*max(1,c)) for c in counts],dtype=np.float32)
    criterion=torch.nn.CrossEntropyLoss(weight=torch.tensor(weights))
    opt=torch.optim.AdamW(model.parameters(),lr=args.lr,weight_decay=1e-4)
    best=-1; best_state=None; history=[]
    for epoch in range(args.epochs):
        model.train(); perm=np.random.permutation(train); losses=[]
        for start in range(0,len(perm),16):
            idx=perm[start:start+16]
            if args.random_crop:
                offs=[np.random.randint(0,len(waves[i])-n+1) if len(waves[i])>n else None for i in idx]
                xb=features([waves[i] for i in idx],offs)
            else: xb=xcenter[idx]
            opt.zero_grad(); logits=model(xb); loss=criterion(logits,yt[idx]); loss.backward(); opt.step(); losses.append(float(loss.item()))
        pv=np.argmax(predict_probs(model,rows,val,args.eval_crops),axis=1) if len(val) else np.array([])
        score=f1_score(y[val],pv,average='macro',zero_division=0) if len(val) else -np.mean(losses)
        history.append({'epoch':epoch+1,'train_loss':float(np.mean(losses)),'validation_macro_f1':float(score)})
        print(f"epoch {epoch+1}/{args.epochs}: loss={np.mean(losses):.4f} val_macro_f1={score:.4f}",flush=True)
        if score>best:
            best=score; best_state={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
    model.load_state_dict(best_state); model.eval()
    ids=sorted(set(int(i) for i in y))
    names={str(i):next((r['species'] for r in rows if r['label']==i),f'class_{i}') for i in ids}
    ev=evaluate_test(model,rows,args.eval_crops,names); t=ev['test']
    metrics={'dataset_files_loaded':len(rows),'split_counts':{s:int((split==s).sum()) for s in ['train','validation','test']},
             'num_classes':32,'test_accuracy':t['accuracy'],'test_macro_f1':t['macro_f1'],'test_weighted_f1':t['weighted_f1'],
             'test_accuracy_ci95':t['accuracy_ci95'],'test_macro_f1_ci95':t['macro_f1_ci95'],
             'test_report':t['report'],'test_session_clean':ev['test_session_clean'],
             'best_validation_macro_f1':float(best),'eval_crops':args.eval_crops,'random_crop':args.random_crop,
             'init_from':args.init_from,'lr':args.lr,'epochs':args.epochs,
             'history':history,'seed':args.seed,'limitations':'Small imbalanced research dataset; results are not evidence of grain-pest detection.'}
    out=Path(args.output); out.mkdir(parents=True,exist_ok=True)
    torch.save(model.state_dict(),out/'model.pt')
    with h5py.File(out/'model.h5','w') as h:
        h.attrs['format']='kernel-pytorch-state-dict-hdf5-v1'
        h.attrs['model_architecture']='InsectCNN'
        h.attrs['model_version']='0.1.0-experimental'
        sd=h.create_group('state_dict')
        for k,v in model.state_dict().items(): sd.create_dataset(k,data=v.detach().cpu().numpy())
    (out/'labels.json').write_text(json.dumps(names,indent=2))
    (out/'config.json').write_text(json.dumps({'model_type':'kernel-insect-cnn','architecture':'InsectCNN','num_classes':32,'sample_rate':16000,'clip_seconds':2.0,'feature':'64-bin log-mel spectrogram','eval_crops':args.eval_crops,'model_version':'0.1.0-experimental','framework':'PyTorch','hdf5_format':'kernel-pytorch-state-dict-hdf5-v1','task':'insect species classification','experimental':True},indent=2))
    (out/'metrics.json').write_text(json.dumps(metrics,indent=2))
    (out/'history.json').write_text(json.dumps(history,indent=2))
    print(json.dumps({k:v for k,v in metrics.items() if k not in ('test_report','history')},indent=2))
if __name__=='__main__': main()