from pathlib import Path import numpy as np import pandas as pd import streamlit as st import plotly.graph_objects as go APP = Path(__file__).resolve().parent DATA = APP/'data' ASSETS = APP/'assets' st.set_page_config(page_title='LoRA-Boost', layout='wide') st.markdown('', unsafe_allow_html=True) COLORS = {'A': '#888888', 'B': '#5e7259', 'C': '#9a7a35', 'D': '#b1502b'} COND_NAME = {'A': 'A · baseline', 'B': 'B · traditional aug', 'C': 'C · FLUX zero-shot', 'D': 'D · LoRA-Boost (ours)'} @st.cache_data def load(name): df = pd.read_csv(DATA/name) if 'species_id' in df.columns: df['species_id'] = df['species_id'].astype(str) return df summary = load('results_summary.csv') per_sp = load('per_species.csv') longtail = load('longtail.csv') rare = load('rare_species.csv') samples = load('samples_index.csv') gallery = load('gallery_index.csv') import json import torch from torchvision.models import resnet50 from torchvision import transforms as TF from safetensors.torch import load_file from PIL import Image MODELS_DIR = APP/'models' @st.cache_resource def load_label_map(): lm = sorted(json.load(open(MODELS_DIR/'label_map.json')), key=lambda x: x['idx']) return [x['name'] for x in lm], [x['species_id'] for x in lm], [x['is_rare'] for x in lm] @st.cache_resource def load_model(cond): names, _, _ = load_label_map() m = resnet50() m.fc = torch.nn.Linear(m.fc.in_features, len(names)) m.load_state_dict({k: v.float() for k, v in load_file(str(MODELS_DIR/f'{cond}.safetensors')).items()}) m.eval() return m infer_tf = TF.Compose([TF.Resize((224, 224)), TF.ToTensor(), TF.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]) @torch.no_grad() def predict(cond, pil_img, k=3): probs = torch.softmax(load_model(cond)(infer_tf(pil_img.convert('RGB')).unsqueeze(0)), 1)[0] top = torch.topk(probs, k) return [(int(i), float(p)) for i, p in zip(top.indices, top.values)] def gamma_label(r): if str(r['loss']) == 'ce': return 'CE (γ=0)' return f"focal γ={int(r['gamma'])}" if pd.notna(r['gamma']) else 'focal' S = summary.copy() S['glabel'] = S.apply(gamma_label, axis=1) S['gnum'] = S.apply(lambda r: 0 if str(r['loss']) == 'ce' else r['gamma'], axis=1) main = S[S.es_monitor == 'overall_f1'] DEFAULT_EXP = main[(main.k == 1) & (main.gnum == 2) & (main.loss == 'focal')].exp_id.iloc[0] def cond_table(exp): return main[main.exp_id == exp].set_index('condition') def style_fig(fig, h=380): fig.update_layout(height=h, template='plotly_white', margin=dict(t=40, l=10, r=10, b=10), legend=dict(orientation='h', yanchor='bottom', y=1.02, x=0), paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)') return fig dft = cond_table(DEFAULT_EXP) d_rec, a_rec = dft.loc['D', 'rare_recall_mean'], dft.loc['A', 'rare_recall_mean'] st.title('LoRA-Boost') st.caption('Generative augmentation for long-tail plant species classification · Pl@ntNet-300K') st.markdown(f'**สรุปสั้นๆ** เราเทรน LoRA แยกแต่ละ species เพื่อสร้างภาพมาเติมพืชหายาก ' f'ช่วยให้โมเดลจำแนกกลุ่มนี้ได้ดีขึ้น (recall เพิ่ม {(d_rec-a_rec):+.3f} จาก baseline) ' f'โดยภาพรวมไม่แย่ลง และดีกว่าการสร้างภาพแบบ zero-shot') st.divider() st.header('The long-tail problem') st.write('Pl@ntNet-300K มี 399 species แต่จำนวนภาพแต่ละ species ต่างกันมาก บางตัวมีเป็นพันภาพ ' 'ขณะที่ 15 rare species ของเรามี 30–99 ภาพ พอข้อมูลน้อยขนาดนี้ โมเดลจึงจำแนกกลุ่มนี้ได้ไม่ดี ' 'เราเลยอยากช่วยเติมภาพให้มันเรียนรู้ได้มากขึ้น') d = longtail.sort_values('n_train', ascending=False).reset_index(drop=True) d['rank'] = range(1, len(d) + 1) rr = d[d.is_rare] fig = go.Figure() fig.add_trace(go.Scatter(x=d['rank'], y=d['n_train'], mode='lines', line=dict(color='#cccccc'), fill='tozeroy', name='all 399 species', hoverinfo='skip')) fig.add_trace(go.Scatter(x=rr['rank'], y=rr['n_train'], mode='markers', marker=dict(color='#b1502b', size=9, line=dict(color='white', width=1)), text=rr['scientific_name'], name='15 rare species', hovertemplate='%{text}
%{y} train imgs')) fig.update_yaxes(type='log', title='train images per species (log scale)') fig.update_xaxes(title='species rank (most → least images)') st.plotly_chart(style_fig(fig, 420), width='stretch') st.markdown('**Controlled subset: what the experiment trains on**') C, R = float(main['C'].iloc[0]), float(main['R'].iloc[0]) kept = longtail[longtail.n_train <= C].copy() kept['eff'] = np.where(kept.is_rare, kept.n_train, np.minimum(kept.n_train, R)) kept = kept.sort_values('eff', ascending=False).reset_index(drop=True) kept['rank'] = range(1, len(kept) + 1) kr = kept[kept.is_rare] figk = go.Figure() figk.add_trace(go.Scatter(x=kept['rank'], y=kept['eff'], mode='lines', line=dict(color='#cccccc'), fill='tozeroy', name=f'kept subset ({len(kept)} classes)', hoverinfo='skip')) figk.add_trace(go.Scatter(x=kr['rank'], y=kr['eff'], mode='markers', marker=dict(color='#b1502b', size=9, line=dict(color='white', width=1)), text=kr['scientific_name'], name='15 rare species', hovertemplate='%{text}
%{y} train imgs')) figk.add_hline(y=C, line_dash='dash', line_color='#888', annotation_text=f'C = {C:.0f} (cut head)', annotation_position='top right') figk.add_hline(y=R, line_dash='dash', line_color='#5e7259', annotation_text=f'R = {R:.0f} (cap)', annotation_position='bottom right') figk.update_yaxes(title='train images per species') figk.update_xaxes(title='kept species rank') st.plotly_chart(style_fig(figk, 360), width='stretch') st.caption(f'ตัด class ที่ภาพเยอะเกิน {C:.0f} ใบออก เหลือ {len(kept)} class แล้วลดเพดาน class ที่ไม่ใช่ rare ' f'ให้เหลือไม่เกิน {R:.0f} ใบ เพื่อให้เทียบผลกันได้แฟร์ขึ้น (จุดสีแดงคือ rare 15 ตัว)') st.markdown('**15 rare species: augmentation target**') st.dataframe(rare.sort_values('n_train'), width='stretch', hide_index=True) st.divider() st.header('How LoRA-Boost works') st.write('เราเทรน LoRA แยกทีละ species (ด้วยเทคนิค DreamBooth) บน FLUX.2-klein แล้วเอาภาพที่สร้างได้มาเติม ' 'ให้พืชหายากตอนเทรนโมเดลจำแนก จุดต่างจาก zero-shot คือ LoRA ได้ดูรูปจริงของ species นั้นมาก่อน ' 'จึงเรียนรู้และสร้างภาพได้ใกล้เคียงกว่า') PIPE = [('NB01', 'clean + split 399 species'), ('NB02', 'train 15 LoRA'), ('NB03', 'generate synthetic'), ('NB04', 'train ResNet-50'), ('NB05', 'error analysis'), ('NB06', 'budget experiment')] box = ('
{t}
' '
{d}
') arrow = '
' st.markdown(f'
' f'{arrow.join(box.format(t=t, d=de) for t, de in PIPE)}
', unsafe_allow_html=True) st.markdown('**Real vs zero-shot vs LoRA-Boost**') opt = {r.scientific_name: r.species_id for r in gallery.itertuples()} sid = opt[st.selectbox('species', list(opt), key='gal')] for col, tag, label in zip(st.columns(3), ['real', 'zeroshot', 'lora'], ['Real (Pl@ntNet)', 'FLUX zero-shot', 'LoRA-Boost (ours)']): col.markdown(f'**{label}**') for im in sorted((ASSETS/'gallery'/sid/tag).glob('*.jpg')): col.image(str(im), width='stretch') st.markdown('LoRA weights ทั้ง 15 species: [huggingface.co/Winnnnnnn/lora-boost-flux](https://huggingface.co/Winnnnnnn/lora-boost-flux)') st.divider() st.header('Does it work?') METRICS = {'rare_recall': 'rare-15 recall (primary)', 'rare_f1': 'rare-15 macro F1', 'rare_precision': 'rare-15 precision', 'overall_f1': 'overall macro F1'} c1, c2 = st.columns([3, 1]) metric = c1.radio('metric', list(METRICS), format_func=lambda m: METRICS[m], horizontal=True) cfgs = main[['exp_id', 'k', 'gnum', 'glabel']].drop_duplicates().sort_values(['k', 'gnum']) cfg_labels = {f'k={r.k} · {r.glabel}': r.exp_id for r in cfgs.itertuples()} keys = list(cfg_labels) default = next((i for i, key in enumerate(keys) if 'k=1.0' in key and 'γ=2' in key), 0) exp = cfg_labels[c2.selectbox('config', keys, index=default)] dd = cond_table(exp) conds = ['A', 'B', 'C', 'D'] m1, m2, m3, m4 = st.columns(4) m1.metric('rare recall (D)', f"{dd.loc['D','rare_recall_mean']:.3f}", f"{dd.loc['D','rare_recall_mean']-dd.loc['A','rare_recall_mean']:+.3f} vs A") m2.metric('rare macro F1 (D)', f"{dd.loc['D','rare_f1_mean']:.3f}", f"{dd.loc['D','rare_f1_mean']-dd.loc['A','rare_f1_mean']:+.3f} vs A") m3.metric('overall macro F1 (D)', f"{dd.loc['D','overall_f1_mean']:.3f}", f"{dd.loc['D','overall_f1_mean']-dd.loc['A','overall_f1_mean']:+.3f} vs A") m4.metric('recall vs zero-shot', f"{dd.loc['D','rare_recall_mean']:.3f}", f"{dd.loc['D','rare_recall_mean']-dd.loc['C','rare_recall_mean']:+.3f} vs C") means = [dd.loc[c, f'{metric}_mean'] for c in conds] stds = [dd.loc[c, f'{metric}_std'] for c in conds] fig = go.Figure(go.Bar(x=[COND_NAME[c] for c in conds], y=means, error_y=dict(type='data', array=stds), marker_color=[COLORS[c] for c in conds], text=[f'{m:.3f}' for m in means], textposition='outside')) fig.update_layout(yaxis_title=METRICS[metric]) st.plotly_chart(style_fig(fig), width='stretch') st.markdown('**Per-species: D vs A**') psm = {'rare_recall': 'recall', 'rare_f1': 'f1', 'rare_precision': 'precision'}.get(metric, 'recall') ps = per_sp[per_sp.exp_id == exp] a = ps[ps.condition == 'A'].set_index('species_id')[psm] dv = ps[ps.condition == 'D'].set_index('species_id')[psm] nm = ps.drop_duplicates('species_id').set_index('species_id').scientific_name delta = (dv - a).dropna().sort_values() fig4 = go.Figure(go.Bar(y=[nm[i] for i in delta.index], x=delta.values, orientation='h', marker_color=['#b1502b' if v < 0 else '#5e7259' for v in delta.values])) fig4.add_vline(x=0, line_color='#333') fig4.update_layout(xaxis_title=f'D − A ({psm}), + = ดีขึ้น') st.plotly_chart(style_fig(fig4, 460), width='stretch') st.divider() st.subheader('ภาพรวมจากทุกการทดลอง') cc1, cc2 = st.columns(2) with cc1: st.markdown('**Budget sweep (γ=2)**') ks = main[(main.gnum == 2) & (main.loss == 'focal')].sort_values('k') pf = ks.pivot_table(index='k', columns='condition', values='rare_f1_mean') pr = ks.pivot_table(index='k', columns='condition', values='rare_recall_mean') fig2 = go.Figure() fig2.add_trace(go.Scatter(x=pf.index, y=pf['D'] - pf['A'], mode='lines+markers', name='Δ rare F1', line=dict(color='#b1502b'))) fig2.add_trace(go.Scatter(x=pr.index, y=pr['D'] - pr['A'], mode='lines+markers', name='Δ recall', line=dict(color='#5e7259'))) fig2.add_hline(y=0, line_dash='dash', line_color='#bbb') fig2.update_layout(xaxis_title='k (synthetic budget)', yaxis_title='D − A') st.plotly_chart(style_fig(fig2, 320), width='stretch') st.caption('เติมภาพพอดี (k=1) ได้ผลดีสุด ถ้าเติมเยอะไป (k=2) กลับแย่ลง เพราะภาพสังเคราะห์เริ่มกลบข้อมูลจริง') with cc2: st.markdown('**Robustness across γ (k=1)**') gg = main[main.k == 1].sort_values('gnum') piv = gg.pivot_table(index='gnum', columns='condition', values=f'{metric}_mean') fig3 = go.Figure() for c in ['A', 'D']: fig3.add_trace(go.Scatter(x=piv.index, y=piv[c], mode='lines+markers', name=c, line=dict(color=COLORS[c]))) fig3.update_layout(xaxis_title='focal γ (0 = CE)', yaxis_title=METRICS[metric]) st.plotly_chart(style_fig(fig3, 320), width='stretch') st.caption('LoRA-Boost (D) ดีกว่า baseline (A) ทุกค่า γ แปลว่าผลที่ได้ไม่ได้ขึ้นกับการตั้งค่าตัวใดตัวหนึ่ง') st.divider() def pred_rows(preds, names, rares, sids, answer_sid): html = '' for i, p in preds: correct = answer_sid is not None and sids[i] == answer_sid bar = '#5e7259' if correct else '#565b6b' txt = '#a9d3a4' if correct else '#dcdce0' nm = names[i] + (' · rare' if rares[i] else '') pct = f'{p*100:.0f}' html += (f'
' f'
' f'{nm}' f'{pct}%
' f'
' f'
') return html st.header('Try it yourself') st.write('เลือกรูปพืชหายากจากตัวอย่าง หรืออัปโหลดรูปเอง แล้วดูว่าแต่ละโมเดลทายว่าเป็น species อะไร 3 อันดับแรก ' '(แท่งสีเขียวคือ species ที่ถูกต้อง)') names, sids, rares = load_label_map() img, answer_sid, answer_name = None, None, None mode = st.radio('mode', ['ตัวอย่าง', 'อัปโหลดเอง'], horizontal=True, label_visibility='collapsed') if mode == 'ตัวอย่าง': pick = st.selectbox('species', sorted(samples.scientific_name.unique()), key='samp') sub = samples[samples.scientific_name == pick].reset_index(drop=True) for c, r in zip(st.columns(len(sub)), sub.itertuples()): c.image(str(ASSETS/r.rel), width='stretch') j = st.radio('เลือกรูปทดสอบ', range(len(sub)), format_func=lambda i: f'รูป {i+1}', horizontal=True) row = sub.iloc[j] img, answer_sid, answer_name = Image.open(ASSETS/row.rel), row.species_id, pick else: up = st.file_uploader('อัปโหลดรูปพืช', type=['jpg', 'jpeg', 'png']) if up: img = Image.open(up) st.image(img, width=240) all4 = st.toggle('เทียบครบ 4 โมเดล (A / B / C / D)', value=False) chosen = ['A', 'B', 'C', 'D'] if all4 else ['A', 'D'] if img is None: st.caption('เลือกหรืออัปโหลดรูปก่อน') else: ncol = 2 if len(chosen) > 2 else len(chosen) # ครบ 4 -> 2x2, A vs D -> 2 ใบเรียงเดียว for r in range(0, len(chosen), ncol): for col, cond in zip(st.columns(ncol), chosen[r:r + ncol]): with col.container(border=True): preds = predict(cond, img) head = f'**{COND_NAME[cond]}**' if answer_sid is not None: head += ' ' + (':green[ถูก]' if sids[preds[0][0]] == answer_sid else ':red[ผิด]') st.markdown(head) st.markdown(pred_rows(preds, names, rares, sids, answer_sid), unsafe_allow_html=True)